From b5f2686ace40ad3e91ebc225d61cc04351827d19 Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Thu, 22 Oct 2026 01:00:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(tournaments):=20=EB=82=9A=EC=8B=9C=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B0=98=EA=B2=BD=20=EC=95=88?= =?UTF-8?q?=EC=9D=98=20=EC=82=AC=EC=A7=84=EB=A7=8C=20=EB=8C=80=ED=9A=8C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: park gun hyung --- .../migration.sql | 4 + fishing/apps/api/prisma/schema.prisma | 3 + .../api/src/catches/catches.service.spec.ts | 24 +++++- .../apps/api/src/catches/catches.service.ts | 19 ++++- .../upload/image-integrity.util.spec.ts | 10 ++- .../src/common/upload/image-integrity.util.ts | 22 ++++++ .../src/tournaments/day-code.service.spec.ts | 2 + .../dto/organizer-tournament.dto.ts | 23 +++++- .../src/tournaments/organizer.service.spec.ts | 57 ++++++++++++++ .../src/tournaments/tournaments.service.ts | 75 ++++++++++++++++++- .../apps/web/src/app/tournament/[id]/page.tsx | 14 ++++ .../web/src/app/tournament/organize/page.tsx | 12 +++ 12 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 fishing/apps/api/prisma/migrations/20261022010000_tournament_spot/migration.sql diff --git a/fishing/apps/api/prisma/migrations/20261022010000_tournament_spot/migration.sql b/fishing/apps/api/prisma/migrations/20261022010000_tournament_spot/migration.sql new file mode 100644 index 0000000..5e197ca --- /dev/null +++ b/fishing/apps/api/prisma/migrations/20261022010000_tournament_spot/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "tournaments" +ADD COLUMN "spot_lat" DECIMAL(9,6), +ADD COLUMN "spot_lng" DECIMAL(9,6), +ADD COLUMN "spot_radius_km" DECIMAL(4,1); diff --git a/fishing/apps/api/prisma/schema.prisma b/fishing/apps/api/prisma/schema.prisma index 482a522..3314c73 100644 --- a/fishing/apps/api/prisma/schema.prisma +++ b/fishing/apps/api/prisma/schema.prisma @@ -338,6 +338,9 @@ model Tournament { minLengthCm Decimal? @map("min_length_cm") @db.Decimal(5, 1) maxLengthCm Decimal? @map("max_length_cm") @db.Decimal(5, 1) sponsorName String? @map("sponsor_name") @db.VarChar(80) + spotLat Decimal? @map("spot_lat") @db.Decimal(9, 6) + spotLng Decimal? @map("spot_lng") @db.Decimal(9, 6) + spotRadiusKm Decimal? @map("spot_radius_km") @db.Decimal(4, 1) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/fishing/apps/api/src/catches/catches.service.spec.ts b/fishing/apps/api/src/catches/catches.service.spec.ts index 2e6cbb1..2cb1295 100644 --- a/fishing/apps/api/src/catches/catches.service.spec.ts +++ b/fishing/apps/api/src/catches/catches.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, ConflictException, ServiceUnavailableException } from '@nestjs/common'; import { CatchesService } from './catches.service'; -import { removeUploadedFile } from '../common/upload/image-integrity.util'; +import { removeUploadedFile, validatePhotoFreshness } from '../common/upload/image-integrity.util'; import { computePerceptualHash } from '../common/upload/image-phash.util'; jest.mock('../common/upload/image-integrity.util', () => { @@ -188,6 +188,28 @@ describe('CatchesService 이미지 해시', () => { ); }); + it('포인트에서 먼 GPS는 대회 코드 업로드를 거절한다', async () => { + (validatePhotoFreshness as jest.Mock).mockResolvedValueOnce({ + ok: true, + exifLat: 35.1796, + exifLng: 129.0756, + takenAt: new Date('2026-10-20T03:00:00.000Z'), + }); + prisma.catch.findFirst.mockResolvedValue(null); + tournaments.resolveDayCodeClaim.mockResolvedValueOnce({ + claimedTournamentId: 'tour-1', + claimedDayCode: 'LAKE1', + dayCodeStatus: 'pending', + spot: { lat: 37.5665, lng: 126.978, radiusKm: 5 }, + }); + + await expect( + service.createCertified('user-1', file, { tournamentId: 'tour-1', dayCode: 'LAKE1' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(removeUploadedFile).toHaveBeenCalledWith(file.path); + expect(prisma.catch.create).not.toHaveBeenCalled(); + }); + 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 89165f4..d8029fe 100644 --- a/fishing/apps/api/src/catches/catches.service.ts +++ b/fishing/apps/api/src/catches/catches.service.ts @@ -7,6 +7,7 @@ import { determineGrade } from '../common/certification/certification-grade.util import { computeRankScore } from '../common/certification/rank-score.util'; import { appendIntegrityNote, + assertWithinSpot, computeImageHash, removeUploadedFile, validatePhotoFreshness, @@ -63,9 +64,21 @@ export class CatchesService { throw error; } - const notedFreshness = claim.claimedTournamentId && !freshness.takenAt - ? { ...freshness, integrityNote: appendIntegrityNote(freshness.integrityNote, 'code_date_unverified') } - : freshness; + let integrityNote = freshness.integrityNote; + if (claim.claimedTournamentId && !freshness.takenAt) { + integrityNote = appendIntegrityNote(integrityNote, 'code_date_unverified'); + } + if (claim.spot) { + const proximity = assertWithinSpot(freshness.exifLat, freshness.exifLng, claim.spot); + if (!proximity.ok) { + await removeUploadedFile(file.path); + throw new BadRequestException(proximity.reason); + } + if (proximity.unverified) { + integrityNote = appendIntegrityNote(integrityNote, 'spot_unverified'); + } + } + const notedFreshness = { ...freshness, integrityNote }; const imageUrl = `/uploads/${file.filename}`; diff --git a/fishing/apps/api/src/common/upload/image-integrity.util.spec.ts b/fishing/apps/api/src/common/upload/image-integrity.util.spec.ts index 4d94f82..825ce4e 100644 --- a/fishing/apps/api/src/common/upload/image-integrity.util.spec.ts +++ b/fishing/apps/api/src/common/upload/image-integrity.util.spec.ts @@ -1,4 +1,4 @@ -import { assertGpsSanity, haversineKm, isPlausibleGps } from './image-integrity.util'; +import { assertGpsSanity, assertWithinSpot, haversineKm, isPlausibleGps } from './image-integrity.util'; describe('GPS 무결성', () => { it('범위 밖과 0,0은 좌표로 보지 않는다', () => { @@ -22,4 +22,12 @@ describe('GPS 무결성', () => { expect(assertGpsSanity(undefined, undefined, { lat: 35, lng: 129 }).ok).toBe(true); expect(assertGpsSanity(0, 0).ok).toBe(false); }); + + it('대회 포인트 반경 밖은 거절하고, GPS가 없으면 확인 불가로 남긴다', () => { + const spot = { lat: 37.5665, lng: 126.978, radiusKm: 5 }; + expect(assertWithinSpot(37.57, 126.98, spot)).toEqual({ ok: true, unverified: false }); + expect(assertWithinSpot(35.1796, 129.0756, spot).ok).toBe(false); + expect(assertWithinSpot(undefined, undefined, spot)).toEqual({ ok: true, unverified: true }); + expect(assertWithinSpot(35.1796, 129.0756, null)).toEqual({ ok: true, unverified: false }); + }); }); 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 9e93f89..7844d57 100644 --- a/fishing/apps/api/src/common/upload/image-integrity.util.ts +++ b/fishing/apps/api/src/common/upload/image-integrity.util.ts @@ -8,6 +8,28 @@ export const CERTIFIED_MAX_PHOTO_AGE_HOURS = 72; /** EXIF GPS와 사용자가 적은 장소가 이보다 멀면 인증 업로드를 거절한다. */ export const GPS_CLAIM_MISMATCH_KM = 50; +/** 대회 포인트를 적고 반경을 비우면 이 거리를 쓴다. */ +export const DEFAULT_SPOT_RADIUS_KM = 5; + +export function assertWithinSpot( + exifLat: number | undefined, + exifLng: number | undefined, + spot: { lat: number; lng: number; radiusKm: number } | null, +): { ok: true; unverified: boolean } | { ok: false; reason: string } { + if (!spot) return { ok: true, unverified: false }; + if (exifLat === undefined || exifLng === undefined || !isPlausibleGps(exifLat, exifLng)) { + return { ok: true, unverified: true }; + } + const distance = haversineKm(exifLat, exifLng, spot.lat, spot.lng); + if (distance > spot.radiusKm) { + return { + ok: false, + reason: `사진 GPS가 대회 포인트에서 ${spot.radiusKm}km보다 멀리 있습니다.`, + }; + } + return { ok: true, unverified: false }; +} + export async function computeImageHash(filePath: string): Promise { const buffer = await readFile(filePath); return createHash('sha256').update(buffer).digest('hex'); 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 c64d3f0..d721346 100644 --- a/fishing/apps/api/src/tournaments/day-code.service.spec.ts +++ b/fishing/apps/api/src/tournaments/day-code.service.spec.ts @@ -52,6 +52,7 @@ describe('TournamentsService 일일 코드', () => { claimedTournamentId: 'tour-1', claimedDayCode: 'LAKE1', dayCodeStatus: 'pending', + spot: null, }); }); @@ -60,6 +61,7 @@ describe('TournamentsService 일일 코드', () => { claimedTournamentId: null, claimedDayCode: null, dayCodeStatus: null, + spot: null, }); expect(prisma.tournamentEntry.findUnique).not.toHaveBeenCalled(); }); diff --git a/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts b/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts index 67e1cf3..89cfcb7 100644 --- a/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts +++ b/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, Min, MinLength } from 'class-validator'; +import { IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; export class OrganizerTournamentDto { @IsString() @@ -73,4 +73,25 @@ export class OrganizerTournamentDto { @IsOptional() @IsIn(['upcoming', 'active', 'closed', 'finished']) status?: 'upcoming' | 'active' | 'closed' | 'finished'; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + spotLat?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + spotLng?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.5) + @Max(30) + spotRadiusKm?: number; } diff --git a/fishing/apps/api/src/tournaments/organizer.service.spec.ts b/fishing/apps/api/src/tournaments/organizer.service.spec.ts index 93afcfd..22e2e55 100644 --- a/fishing/apps/api/src/tournaments/organizer.service.spec.ts +++ b/fishing/apps/api/src/tournaments/organizer.service.spec.ts @@ -41,9 +41,35 @@ describe('TournamentsService 운영자 대회', () => { region: '경남 거제', minLengthCm: 25, prize: '상품권', + spotLat: null, + spotLng: null, + spotRadiusKm: null, })); }); + it('포인트 좌표는 기본 반경 5km로 저장하고, 위도만 있으면 거절한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.create.mockImplementation(async ({ data }) => data); + + const created = await service.createForOrganizer('org-1', { + ...dto, + spotLat: 34.88, + spotLng: 128.62, + }); + expect(created).toEqual(expect.objectContaining({ + spotLat: 34.88, + spotLng: 128.62, + spotRadiusKm: 5, + })); + + await expect( + service.createForOrganizer('org-1', { ...dto, spotLat: 34.88 }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.createForOrganizer('org-1', { ...dto, spotLat: 0, spotLng: 0 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + it('참가비가 있으면 거절한다', async () => { prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); await expect( @@ -136,4 +162,35 @@ describe('TournamentsService 운영자 대회', () => { ); expect(prisma.catch.update).not.toHaveBeenCalled(); }); + + it('코드 제출 목록은 포인트까지 거리만 보여 주고 원본 GPS는 빼 준다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.findUnique.mockResolvedValue({ + id: 'tour-1', + organizerId: 'org-1', + spotLat: 37.5665, + spotLng: 126.978, + spotRadiusKm: 5, + }); + prisma.catch.findMany.mockResolvedValue([{ + id: 'catch-1', + lengthCm: 30, + status: 'approved', + imageUrl: '/uploads/a.jpg', + claimedDayCode: 'SHOP1', + dayCodeStatus: 'pending', + integrityNote: null, + photoTakenAt: null, + exifLat: 37.57, + exifLng: 126.98, + createdAt: new Date('2026-10-20T03:00:00.000Z'), + user: { id: 'user-1', nickname: 'angler' }, + }]); + + const rows = await service.listDayCodeClaims('org-1', 'tour-1'); + + expect(rows[0].spotDistanceKm).toBeLessThan(5); + expect(rows[0]).not.toHaveProperty('exifLat'); + expect(JSON.stringify(rows)).not.toContain('37.57'); + }); }); diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index af21ce1..444ac67 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -1,18 +1,23 @@ import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { haversineKm, isPlausibleGps } from '../common/upload/image-integrity.util'; import { assertDayCodePhoto, normalizeDayCode, seoulDateKey } from './day-code.util'; import { buildLiveLeaderboard, leaderboardCsv } from './live-leaderboard.util'; +export type TournamentSpot = { lat: number; lng: number; radiusKm: number }; + export type DayCodeClaim = { claimedTournamentId: string | null; claimedDayCode: string | null; dayCodeStatus: string | null; + spot: TournamentSpot | null; }; const EMPTY_DAY_CODE_CLAIM: DayCodeClaim = { claimedTournamentId: null, claimedDayCode: null, dayCodeStatus: null, + spot: null, }; @Injectable() @@ -38,6 +43,7 @@ export class TournamentsService { where: { id }, include: { _count: { select: { entries: true, dayCodes: true } }, + organizer: { select: { nickname: true } }, entries: { where: { rank: { not: null } }, include: { user: { select: { id: true, nickname: true, profileImage: true } } }, @@ -296,7 +302,12 @@ export class TournamentsService { if (!photo.ok) throw new BadRequestException(photo.reason); } - return { claimedTournamentId: tournamentId, claimedDayCode: code, dayCodeStatus: 'pending' }; + return { + claimedTournamentId: tournamentId, + claimedDayCode: code, + dayCodeStatus: 'pending', + spot: readTournamentSpot(tournament), + }; } async listParticipants(actorId: string, tournamentId: string) { @@ -321,8 +332,9 @@ export class TournamentsService { } async listDayCodeClaims(actorId: string, tournamentId: string) { - await this.assertManagesTournament(actorId, tournamentId); - return this.prisma.catch.findMany({ + const tournament = await this.assertManagesTournament(actorId, tournamentId); + const spot = readTournamentSpot(tournament); + const rows = await this.prisma.catch.findMany({ where: { deletedAt: null, claimedTournamentId: tournamentId, @@ -337,12 +349,32 @@ export class TournamentsService { dayCodeStatus: true, integrityNote: true, photoTakenAt: true, + exifLat: true, + exifLng: true, createdAt: true, user: { select: { id: true, nickname: true } }, }, orderBy: { createdAt: 'desc' }, take: 100, }); + return rows.map((row) => { + const distance = spot && row.exifLat != null && row.exifLng != null + ? Math.round(haversineKm(Number(row.exifLat), Number(row.exifLng), spot.lat, spot.lng) * 10) / 10 + : null; + return { + id: row.id, + lengthCm: row.lengthCm, + status: row.status, + imageUrl: row.imageUrl, + claimedDayCode: row.claimedDayCode, + dayCodeStatus: row.dayCodeStatus, + integrityNote: row.integrityNote, + photoTakenAt: row.photoTakenAt, + createdAt: row.createdAt, + user: row.user, + spotDistanceKm: distance, + }; + }); } async reviewDayCodeClaim( @@ -471,8 +503,29 @@ export class TournamentsService { endAt, rules: dto.rules?.trim() || null, status: dto.status || 'upcoming', + ...this.parseSpot(dto), }; } + + private parseSpot(dto: OrganizerTournamentWrite) { + const lat = dto.spotLat ?? null; + const lng = dto.spotLng ?? null; + const radius = dto.spotRadiusKm ?? null; + if (lat == null && lng == null && radius == null) { + return { spotLat: null, spotLng: null, spotRadiusKm: null }; + } + if (lat == null || lng == null) { + throw new BadRequestException('포인트 위도와 경도를 함께 입력해 주세요.'); + } + if (!isPlausibleGps(lat, lng)) { + throw new BadRequestException('포인트 좌표가 올바르지 않습니다.'); + } + const spotRadiusKm = radius ?? 5; + if (spotRadiusKm < 0.5 || spotRadiusKm > 30) { + throw new BadRequestException('포인트 반경은 0.5~30km입니다.'); + } + return { spotLat: lat, spotLng: lng, spotRadiusKm }; + } } type OrganizerTournamentWrite = { @@ -493,4 +546,20 @@ type OrganizerTournamentWrite = { status?: string; entryFee?: number; isFree?: boolean; + spotLat?: number | null; + spotLng?: number | null; + spotRadiusKm?: number | null; }; + +function readTournamentSpot(tournament: { + spotLat?: unknown; + spotLng?: unknown; + spotRadiusKm?: unknown; +}): TournamentSpot | null { + if (tournament.spotLat == null || tournament.spotLng == null) return null; + const lat = Number(tournament.spotLat); + const lng = Number(tournament.spotLng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + const radius = tournament.spotRadiusKm == null ? 5 : Number(tournament.spotRadiusKm); + return { lat, lng, radiusKm: Number.isFinite(radius) ? radius : 5 }; +} diff --git a/fishing/apps/web/src/app/tournament/[id]/page.tsx b/fishing/apps/web/src/app/tournament/[id]/page.tsx index 5bd64c6..2ee2828 100644 --- a/fishing/apps/web/src/app/tournament/[id]/page.tsx +++ b/fishing/apps/web/src/app/tournament/[id]/page.tsx @@ -99,6 +99,7 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id: )}
+ {data.organizer?.nickname ? `주최 ${data.organizer.nickname} · ` : ''} {new Date(data.startAt).toLocaleDateString('ko-KR')} ~{' '} {new Date(data.endAt).toLocaleDateString('ko-KR')} · 참가 {data._count?.entries ?? 0}명
@@ -119,6 +120,19 @@ export default function TournamentDetailPage({ params }: { params: Promise<{ id: )} + {data.spotLat != null && data.spotLng != null && ( +
+

현장 포인트

+

+ 위도 {Number(data.spotLat).toFixed(5)}, 경도 {Number(data.spotLng).toFixed(5)} + {' · '}반경 {Number(data.spotRadiusKm ?? 5)}km +

+

+ 이 반경 안에서 찍은 사진만 오늘 코드로 받을 수 있습니다. GPS가 없는 사진은 운영자가 확인합니다. +

+
+ )} + {data.prize && (

🏆 후원 상

diff --git a/fishing/apps/web/src/app/tournament/organize/page.tsx b/fishing/apps/web/src/app/tournament/organize/page.tsx index 80a2caf..1afd248 100644 --- a/fishing/apps/web/src/app/tournament/organize/page.tsx +++ b/fishing/apps/web/src/app/tournament/organize/page.tsx @@ -18,6 +18,7 @@ const NOTE_LABELS: Record = { date_missing: '촬영 시각 없음', gps_missing: 'GPS 없음', code_date_unverified: '촬영일을 코드 날짜와 대조하지 못함', + spot_unverified: '포인트 GPS를 확인하지 못함', }; function noteLabel(note?: string | null) { @@ -40,6 +41,9 @@ const EMPTY = { startAt: '', endAt: '', rules: '', + spotLat: '', + spotLng: '', + spotRadiusKm: '', }; export default function OrganizeTournamentPage() { @@ -71,6 +75,9 @@ export default function OrganizeTournamentPage() { startAt: new Date(form.startAt).toISOString(), endAt: new Date(form.endAt).toISOString(), rules: form.rules || undefined, + spotLat: form.spotLat ? Number(form.spotLat) : undefined, + spotLng: form.spotLng ? Number(form.spotLng) : undefined, + spotRadiusKm: form.spotRadiusKm ? Number(form.spotRadiusKm) : undefined, status: 'upcoming', }; return (await api.post('/tournaments/organized', body)).data.data; @@ -174,6 +181,9 @@ export default function OrganizeTournamentPage() { + + + @@ -259,6 +269,7 @@ export default function OrganizeTournamentPage() { dayCodeStatus?: string | null; integrityNote?: string | null; photoTakenAt?: string | null; + spotDistanceKm?: number | null; user: { nickname: string }; }) => { const image = getImageUrl(claim.imageUrl); @@ -274,6 +285,7 @@ export default function OrganizeTournamentPage() { {claim.lengthCm ? ` · ${Number(claim.lengthCm).toFixed(1)}cm` : ''} {' · 인증 '}{claim.status} {taken ? ` · 촬영 ${taken}` : ''} + {claim.spotDistanceKm != null ? ` · 포인트 ${claim.spotDistanceKm}km` : ''} {flags ? ` · ${flags}` : ''} {image && ( <>