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
41 changes: 36 additions & 5 deletions fishing/apps/api/src/catches/catches.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { CatchesService } from './catches.service';
import { removeUploadedFile } from '../common/upload/image-integrity.util';
import { computePerceptualHash } from '../common/upload/image-phash.util';

jest.mock('../common/upload/image-integrity.util', () => ({
computeImageHash: jest.fn().mockResolvedValue('hash-1'),
removeUploadedFile: jest.fn().mockResolvedValue(undefined),
validatePhotoFreshness: jest.fn().mockResolvedValue({ ok: true }),
}));
jest.mock('../common/upload/image-integrity.util', () => {
const actual = jest.requireActual('../common/upload/image-integrity.util');
return {
...actual,
computeImageHash: jest.fn().mockResolvedValue('hash-1'),
removeUploadedFile: jest.fn().mockResolvedValue(undefined),
validatePhotoFreshness: jest.fn().mockResolvedValue({ ok: true }),
};
});

jest.mock('../common/upload/image-phash.util', () => {
const actual = jest.requireActual('../common/upload/image-phash.util');
Expand Down Expand Up @@ -157,6 +161,33 @@ describe('CatchesService 이미지 해시', () => {
expect(JSON.stringify(result)).not.toContain('LAKE1');
});

it('촬영 시각이 없는 대회 제출은 확인 불가로 남긴다', async () => {
prisma.catch.findFirst.mockResolvedValue(null);
prisma.catch.findMany.mockResolvedValue([]);
tournaments.resolveDayCodeClaim.mockResolvedValueOnce({
claimedTournamentId: 'tour-1',
claimedDayCode: 'LAKE1',
dayCodeStatus: 'pending',
});
prisma.catch.create.mockResolvedValue({ id: 'new-catch' });
prisma.certification.deleteMany.mockResolvedValue({ count: 0 });
prisma.catch.delete.mockResolvedValue({});
aiService.analyze.mockRejectedValue(new ServiceUnavailableException('AI down'));

await expect(
service.createCertified('user-1', file, { tournamentId: 'tour-1', dayCode: 'LAKE1' }),
).rejects.toBeInstanceOf(ServiceUnavailableException);

expect(prisma.catch.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
claimedDayCode: 'LAKE1',
integrityNote: 'code_date_unverified',
}),
}),
);
});

it('자랑 기록을 지우면 이미지 해시를 비운다', async () => {
prisma.catch.findUnique.mockResolvedValue({
id: 'catch-1',
Expand Down
9 changes: 7 additions & 2 deletions fishing/apps/api/src/catches/catches.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveSpeciesId } from '../ai/species-slug.util';
import { determineGrade } from '../common/certification/certification-grade.util';
import { computeRankScore } from '../common/certification/rank-score.util';
import {
appendIntegrityNote,
computeImageHash,
removeUploadedFile,
validatePhotoFreshness,
Expand Down Expand Up @@ -56,12 +57,16 @@ export class CatchesService {

let claim;
try {
claim = await this.tournaments.resolveDayCodeClaim(userId, body);
claim = await this.tournaments.resolveDayCodeClaim(userId, body, new Date(), freshness.takenAt);
} catch (error) {
await removeUploadedFile(file.path);
throw error;
}

const notedFreshness = claim.claimedTournamentId && !freshness.takenAt
? { ...freshness, integrityNote: appendIntegrityNote(freshness.integrityNote, 'code_date_unverified') }
: freshness;

const imageUrl = `/uploads/${file.filename}`;

const catch_ = await this.createCertifiedRow(
Expand All @@ -71,7 +76,7 @@ export class CatchesService {
imageHash,
perceptualHash,
body,
freshness,
notedFreshness,
claim,
);

Expand Down
6 changes: 6 additions & 0 deletions fishing/apps/api/src/common/upload/image-integrity.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export type PhotoFreshnessResult =

export type ClaimedLocation = { lat?: number; lng?: number };

export function appendIntegrityNote(note: string | undefined, flag: string): string {
const parts = new Set((note ?? '').split(',').map((part) => part.trim()).filter(Boolean));
parts.add(flag);
return [...parts].join(',');
}

export function isPlausibleGps(lat: number, lng: number): boolean {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false;
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return false;
Expand Down
43 changes: 41 additions & 2 deletions fishing/apps/api/src/tournaments/day-code.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ describe('TournamentsService 일일 코드', () => {

beforeEach(() => {
jest.clearAllMocks();
prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1' });
prisma.tournament.findUnique.mockResolvedValue({
id: 'tour-1',
status: 'active',
startAt: new Date('2026-10-01T00:00:00.000Z'),
endAt: new Date('2026-11-01T00:00:00.000Z'),
});
});

it('관리자가 서울 날짜 코드를 저장한다', async () => {
Expand Down Expand Up @@ -42,7 +47,7 @@ describe('TournamentsService 일일 코드', () => {
prisma.tournamentDayCode.findUnique.mockResolvedValue({ code: 'LAKE1' });

await expect(
service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'lake1' }, now),
service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'lake1' }, now, now),
).resolves.toEqual({
claimedTournamentId: 'tour-1',
claimedDayCode: 'LAKE1',
Expand Down Expand Up @@ -124,4 +129,38 @@ describe('TournamentsService 일일 코드', () => {
service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'RIVER' }, now),
).rejects.toBeInstanceOf(BadRequestException);
});

it('실격 참가자와 진행 중이 아닌 대회는 코드를 받지 않는다', async () => {
prisma.tournamentEntry.findUnique.mockResolvedValue({ paymentStatus: 'free', disqualified: true });
prisma.tournamentDayCode.findUnique.mockResolvedValue({ code: 'LAKE1' });
await expect(
service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'LAKE1' }, now, now),
).rejects.toBeInstanceOf(BadRequestException);

prisma.tournament.findUnique.mockResolvedValue({
id: 'tour-1',
status: 'upcoming',
startAt: new Date('2026-10-01T00:00:00.000Z'),
endAt: new Date('2026-11-01T00:00:00.000Z'),
});
prisma.tournamentEntry.findUnique.mockResolvedValue({ paymentStatus: 'free', disqualified: false });
await expect(
service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'LAKE1' }, now, now),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.tournamentDayCode.findUnique).not.toHaveBeenCalled();
});

it('다른 날 사진은 오늘 코드로 받지 않는다', async () => {
prisma.tournamentEntry.findUnique.mockResolvedValue({ paymentStatus: 'free', disqualified: false });
prisma.tournamentDayCode.findUnique.mockResolvedValue({ code: 'LAKE1' });

await expect(
service.resolveDayCodeClaim(
'user-1',
{ tournamentId: 'tour-1', dayCode: 'LAKE1' },
now,
new Date('2026-10-19T03:00:00.000Z'),
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
28 changes: 27 additions & 1 deletion fishing/apps/api/src/tournaments/day-code.util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { normalizeDayCode, seoulDateKey } from './day-code.util';
import { assertDayCodePhoto, normalizeDayCode, seoulDateKey } from './day-code.util';

describe('대회 일일 코드', () => {
it('서울 날짜 기준으로 날짜 키를 만든다', () => {
Expand All @@ -12,4 +12,30 @@ describe('대회 일일 코드', () => {
expect(normalizeDayCode('한글코드')).toBeNull();
expect(normalizeDayCode(12)).toBeNull();
});

it('촬영일이 코드 날짜와 같고 대회 기간 안이면 통과한다', () => {
const takenAt = new Date('2026-10-20T03:00:00.000Z');
expect(assertDayCodePhoto({
takenAt,
codeDate: '2026-10-20',
startAt: new Date('2026-10-19T15:00:00.000Z'),
endAt: new Date('2026-10-21T15:00:00.000Z'),
})).toEqual({ ok: true });
});

it('다른 날 사진과 기간 밖 사진은 거절한다', () => {
const window = {
codeDate: '2026-10-20',
startAt: new Date('2026-10-20T06:00:00.000Z'),
endAt: new Date('2026-10-21T15:00:00.000Z'),
};
expect(assertDayCodePhoto({
...window,
takenAt: new Date('2026-10-19T03:00:00.000Z'),
}).ok).toBe(false);
expect(assertDayCodePhoto({
...window,
takenAt: new Date('2026-10-20T03:00:00.000Z'),
}).ok).toBe(false);
});
});
25 changes: 25 additions & 0 deletions fishing/apps/api/src/tournaments/day-code.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,28 @@ export function normalizeDayCode(value: unknown): string | null {
const code = value.trim().toUpperCase();
return CODE_PATTERN.test(code) ? code : null;
}

/**
* 오늘 코드를 받은 사진은 그 코드의 서울 날짜에 찍혔고, 대회 기간 안이어야 한다.
* 촬영 시각이 없는 경우는 여기서 통과시키고, 호출하는 쪽이 확인 불가로 남긴다.
*/
export function assertDayCodePhoto(input: {
takenAt: Date;
codeDate: string;
startAt: Date;
endAt: Date;
}): { ok: true } | { ok: false; reason: string } {
if (Number.isNaN(input.takenAt.getTime())) {
return { ok: false, reason: '촬영 시각을 확인할 수 없습니다.' };
}
if (seoulDateKey(input.takenAt) !== input.codeDate) {
return {
ok: false,
reason: '촬영일이 오늘 대회 코드의 날짜와 다릅니다. 오늘 찍은 사진을 올려주세요.',
};
}
if (input.takenAt < input.startAt || input.takenAt > input.endAt) {
return { ok: false, reason: '촬영 시각이 대회 기간 밖입니다.' };
}
return { ok: true };
}
13 changes: 12 additions & 1 deletion fishing/apps/api/src/tournaments/organizer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('TournamentsService 운영자 대회', () => {
it('운영자는 자기 대회에 제출된 코드만 수락한다', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' });
prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' });
prisma.catch.findFirst.mockResolvedValue({ id: 'catch-1', claimedDayCode: 'SHOP1' });
prisma.catch.findFirst.mockResolvedValue({ id: 'catch-1', claimedDayCode: 'SHOP1', status: 'approved' });
prisma.catch.update.mockResolvedValue({ id: 'catch-1', dayCodeStatus: 'accepted' });

await expect(service.reviewDayCodeClaim('org-1', 'tour-1', 'catch-1', 'accepted')).resolves.toEqual({
Expand All @@ -125,4 +125,15 @@ describe('TournamentsService 운영자 대회', () => {
);
expect(prisma.catch.update).not.toHaveBeenCalled();
});

it('반려된 인증은 코드 수락을 거절한다', async () => {
prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' });
prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' });
prisma.catch.findFirst.mockResolvedValue({ id: 'catch-1', claimedDayCode: 'SHOP1', status: 'rejected' });

await expect(service.reviewDayCodeClaim('org-1', 'tour-1', 'catch-1', 'accepted')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.catch.update).not.toHaveBeenCalled();
});
});
32 changes: 30 additions & 2 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeDayCode, seoulDateKey } from './day-code.util';
import { assertDayCodePhoto, normalizeDayCode, seoulDateKey } from './day-code.util';
import { buildLiveLeaderboard, leaderboardCsv } from './live-leaderboard.util';

export type DayCodeClaim = {
Expand Down Expand Up @@ -247,7 +247,12 @@ export class TournamentsService {
});
}

async resolveDayCodeClaim(userId: string, body: { tournamentId?: unknown; dayCode?: unknown }, now = new Date()): Promise<DayCodeClaim> {
async resolveDayCodeClaim(
userId: string,
body: { tournamentId?: unknown; dayCode?: unknown },
now = new Date(),
takenAt?: Date,
): Promise<DayCodeClaim> {
const tournamentId = typeof body.tournamentId === 'string' ? body.tournamentId.trim() : '';
const rawCode = typeof body.dayCode === 'string' ? body.dayCode : '';
if (!tournamentId && !rawCode.trim()) return EMPTY_DAY_CODE_CLAIM;
Expand All @@ -258,12 +263,21 @@ export class TournamentsService {
const code = normalizeDayCode(rawCode);
if (!code) throw new BadRequestException('대회 코드는 4~12자의 영문 또는 숫자여야 합니다.');

const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } });
if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.');
if (tournament.status !== 'active' || now < tournament.startAt || now > tournament.endAt) {
throw new BadRequestException('진행 중인 대회 기간에만 코드를 제출할 수 있습니다.');
}

const entry = await this.prisma.tournamentEntry.findUnique({
where: { tournamentId_userId: { tournamentId, userId } },
});
if (!entry || (entry.paymentStatus !== 'free' && entry.paymentStatus !== 'paid')) {
throw new BadRequestException('참가 중인 대회만 코드를 제출할 수 있습니다.');
}
if (entry.disqualified) {
throw new BadRequestException('실격된 대회에는 코드를 제출할 수 없습니다.');
}

const codeDate = seoulDateKey(now);
const stored = await this.prisma.tournamentDayCode.findUnique({
Expand All @@ -272,6 +286,16 @@ export class TournamentsService {
if (!stored) throw new BadRequestException('오늘 대회 코드가 아직 없습니다.');
if (stored.code !== code) throw new BadRequestException('대회 코드가 맞지 않습니다.');

if (takenAt) {
const photo = assertDayCodePhoto({
takenAt,
codeDate,
startAt: tournament.startAt,
endAt: tournament.endAt,
});
if (!photo.ok) throw new BadRequestException(photo.reason);
}

return { claimedTournamentId: tournamentId, claimedDayCode: code, dayCodeStatus: 'pending' };
}

Expand Down Expand Up @@ -312,6 +336,7 @@ export class TournamentsService {
claimedDayCode: true,
dayCodeStatus: true,
integrityNote: true,
photoTakenAt: true,
createdAt: true,
user: { select: { id: true, nickname: true } },
},
Expand All @@ -336,6 +361,9 @@ export class TournamentsService {
},
});
if (!catch_) throw new NotFoundException('이 대회에 제출된 코드를 찾을 수 없습니다.');
if (status === 'accepted' && catch_.status === 'rejected') {
throw new BadRequestException('반려된 인증은 대회에 넣을 수 없습니다.');
}
return this.prisma.catch.update({
where: { id: catchId },
data: { dayCodeStatus: status },
Expand Down
6 changes: 6 additions & 0 deletions fishing/apps/web/src/app/tournament/organize/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const STATUS_OPTIONS = [
const NOTE_LABELS: Record<string, string> = {
date_missing: '촬영 시각 없음',
gps_missing: 'GPS 없음',
code_date_unverified: '촬영일을 코드 날짜와 대조하지 못함',
};

function noteLabel(note?: string | null) {
Expand Down Expand Up @@ -257,17 +258,22 @@ export default function OrganizeTournamentPage() {
claimedDayCode?: string | null;
dayCodeStatus?: string | null;
integrityNote?: string | null;
photoTakenAt?: string | null;
user: { nickname: string };
}) => {
const image = getImageUrl(claim.imageUrl);
const flags = noteLabel(claim.integrityNote);
const taken = claim.photoTakenAt
? new Date(claim.photoTakenAt).toLocaleString('ko-KR')
: '';
return (
<li key={claim.id}>
{claim.user.nickname}
{' · '}{claim.claimedDayCode}
{' · '}{claim.dayCodeStatus}
{claim.lengthCm ? ` · ${Number(claim.lengthCm).toFixed(1)}cm` : ''}
{' · 인증 '}{claim.status}
{taken ? ` · 촬영 ${taken}` : ''}
{flags ? ` · ${flags}` : ''}
{image && (
<>
Expand Down
Loading