diff --git a/fishing/apps/api/src/catches/catches.service.spec.ts b/fishing/apps/api/src/catches/catches.service.spec.ts index 3eb7be2..2e6cbb1 100644 --- a/fishing/apps/api/src/catches/catches.service.spec.ts +++ b/fishing/apps/api/src/catches/catches.service.spec.ts @@ -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'); @@ -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', diff --git a/fishing/apps/api/src/catches/catches.service.ts b/fishing/apps/api/src/catches/catches.service.ts index 86005cf..89165f4 100644 --- a/fishing/apps/api/src/catches/catches.service.ts +++ b/fishing/apps/api/src/catches/catches.service.ts @@ -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, @@ -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( @@ -71,7 +76,7 @@ export class CatchesService { imageHash, perceptualHash, body, - freshness, + notedFreshness, claim, ); diff --git a/fishing/apps/api/src/common/upload/image-integrity.util.ts b/fishing/apps/api/src/common/upload/image-integrity.util.ts index 25796a0..9e93f89 100644 --- a/fishing/apps/api/src/common/upload/image-integrity.util.ts +++ b/fishing/apps/api/src/common/upload/image-integrity.util.ts @@ -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; diff --git a/fishing/apps/api/src/tournaments/day-code.service.spec.ts b/fishing/apps/api/src/tournaments/day-code.service.spec.ts index 1c9c489..c64d3f0 100644 --- a/fishing/apps/api/src/tournaments/day-code.service.spec.ts +++ b/fishing/apps/api/src/tournaments/day-code.service.spec.ts @@ -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 () => { @@ -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', @@ -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); + }); }); diff --git a/fishing/apps/api/src/tournaments/day-code.util.spec.ts b/fishing/apps/api/src/tournaments/day-code.util.spec.ts index c6bf6a4..b016079 100644 --- a/fishing/apps/api/src/tournaments/day-code.util.spec.ts +++ b/fishing/apps/api/src/tournaments/day-code.util.spec.ts @@ -1,4 +1,4 @@ -import { normalizeDayCode, seoulDateKey } from './day-code.util'; +import { assertDayCodePhoto, normalizeDayCode, seoulDateKey } from './day-code.util'; describe('대회 일일 코드', () => { it('서울 날짜 기준으로 날짜 키를 만든다', () => { @@ -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); + }); }); diff --git a/fishing/apps/api/src/tournaments/day-code.util.ts b/fishing/apps/api/src/tournaments/day-code.util.ts index db308ca..cbff1c5 100644 --- a/fishing/apps/api/src/tournaments/day-code.util.ts +++ b/fishing/apps/api/src/tournaments/day-code.util.ts @@ -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 }; +} diff --git a/fishing/apps/api/src/tournaments/organizer.service.spec.ts b/fishing/apps/api/src/tournaments/organizer.service.spec.ts index abfabc0..93afcfd 100644 --- a/fishing/apps/api/src/tournaments/organizer.service.spec.ts +++ b/fishing/apps/api/src/tournaments/organizer.service.spec.ts @@ -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({ @@ -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(); + }); }); diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index 246daea..af21ce1 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -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 = { @@ -247,7 +247,12 @@ export class TournamentsService { }); } - async resolveDayCodeClaim(userId: string, body: { tournamentId?: unknown; dayCode?: unknown }, now = new Date()): Promise { + async resolveDayCodeClaim( + userId: string, + body: { tournamentId?: unknown; dayCode?: unknown }, + now = new Date(), + takenAt?: Date, + ): Promise { 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; @@ -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({ @@ -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' }; } @@ -312,6 +336,7 @@ export class TournamentsService { claimedDayCode: true, dayCodeStatus: true, integrityNote: true, + photoTakenAt: true, createdAt: true, user: { select: { id: true, nickname: true } }, }, @@ -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 }, diff --git a/fishing/apps/web/src/app/tournament/organize/page.tsx b/fishing/apps/web/src/app/tournament/organize/page.tsx index edc80ad..80a2caf 100644 --- a/fishing/apps/web/src/app/tournament/organize/page.tsx +++ b/fishing/apps/web/src/app/tournament/organize/page.tsx @@ -17,6 +17,7 @@ const STATUS_OPTIONS = [ const NOTE_LABELS: Record = { date_missing: '촬영 시각 없음', gps_missing: 'GPS 없음', + code_date_unverified: '촬영일을 코드 날짜와 대조하지 못함', }; function noteLabel(note?: string | null) { @@ -257,10 +258,14 @@ 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 (
  • {claim.user.nickname} @@ -268,6 +273,7 @@ export default function OrganizeTournamentPage() { {' · '}{claim.dayCodeStatus} {claim.lengthCm ? ` · ${Number(claim.lengthCm).toFixed(1)}cm` : ''} {' · 인증 '}{claim.status} + {taken ? ` · 촬영 ${taken}` : ''} {flags ? ` · ${flags}` : ''} {image && ( <>