Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions fishing/apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
24 changes: 23 additions & 1 deletion fishing/apps/api/src/catches/catches.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 16 additions & 3 deletions fishing/apps/api/src/catches/catches.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`;

Expand Down
10 changes: 9 additions & 1 deletion fishing/apps/api/src/common/upload/image-integrity.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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은 좌표로 보지 않는다', () => {
Expand All @@ -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 });
});
});
22 changes: 22 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 @@ -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<string> {
const buffer = await readFile(filePath);
return createHash('sha256').update(buffer).digest('hex');
Expand Down
2 changes: 2 additions & 0 deletions fishing/apps/api/src/tournaments/day-code.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('TournamentsService 일일 코드', () => {
claimedTournamentId: 'tour-1',
claimedDayCode: 'LAKE1',
dayCodeStatus: 'pending',
spot: null,
});
});

Expand All @@ -60,6 +61,7 @@ describe('TournamentsService 일일 코드', () => {
claimedTournamentId: null,
claimedDayCode: null,
dayCodeStatus: null,
spot: null,
});
expect(prisma.tournamentEntry.findUnique).not.toHaveBeenCalled();
});
Expand Down
23 changes: 22 additions & 1 deletion fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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;
}
57 changes: 57 additions & 0 deletions fishing/apps/api/src/tournaments/organizer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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');
});
});
75 changes: 72 additions & 3 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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 } } },
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 };
}
Loading
Loading