feat(backend): 축제·공연·행사 동기화와 조회 API 추가

홈 '이번 주말 축제·행사'와 장소 상세 '근처 축제·행사'에 쓸 데이터를 채운다.

- place:sync -- event: 관광공사 searchFestival2 를 오늘~90일 기간으로 받아
  festival_event 에 upsert(서버는 기간이 겹치는 행사를 준다). 지역 조건을 보내고
  응답에서도 다른 지역 행사는 거른다. 상세(소개·요금·공연 시간·장소)는 새로 생기거나
  수정된 행사만 받는다. 기간 안에서 목록에 없는 행사는 숨긴다
- GET /api/events: 기간(기본 오늘~30일, 최대 92일)·근처(좌표+반경)·분류·커서.
  60일 넘는 상시 프로그램과 숨김 행사는 뺀다. 항목에 오늘 진행 중 여부
- GET /api/events/{id}: 소개·공연 시간·요금·장소·홈페이지·출처
- 마이그레이션: festival_event 에 분류 코드·지역 코드·장소명·전화·홈페이지·
  작은 사진·원본 수정일·상세 수신 시각 추가

e2e(test/festival.e2e-spec.ts)는 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
2026-11-03 실측: 앞으로 90일 관광공사 행사는 전국 106건, 대구 1건, 경북 7건.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 19:30:08 +09:00
parent 55f10c5c7a
commit 295d74f4d3
16 changed files with 1437 additions and 14 deletions
+21 -2
View File
@@ -3,6 +3,10 @@ import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import {
DEFAULT_EVENT_LOOKAHEAD_DAYS,
FestivalSync,
} from '@/modules/place/sync/festival.sync';
import { PlaceDedupe } from '@/modules/place/sync/place-dedupe';
import { PlaceHoursReparse } from '@/modules/place/sync/place-hours-reparse';
import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
@@ -32,6 +36,9 @@ const USAGE = `사용법: place:sync <작업> [옵션]
tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출)
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
--region=27 법정동 시·도 코드 (기본 27 대구)
--days=90 며칠 뒤까지 받을지
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
status 지역별 적재 현황과 오늘 쓴 호출 수
@@ -41,6 +48,7 @@ const JOBS = [
'tour-kor',
'tour-pet',
'store',
'event',
'dedupe',
'reparse-hours',
'status',
@@ -52,6 +60,7 @@ function parseArgs(argv: string[]): {
region: string;
skipDetails: boolean;
signgu: string[];
days: number;
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
@@ -67,19 +76,27 @@ function parseArgs(argv: string[]): {
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_STORE_SIGNGU;
const daysArg = rest.find((a) => a.startsWith('--days='));
const days = daysArg
? Number(daysArg.slice('--days='.length))
: DEFAULT_EVENT_LOOKAHEAD_DAYS;
return {
job,
region,
skipDetails: rest.includes('--skip-details'),
signgu,
days,
};
}
async function main(): Promise<void> {
const { job, region, skipDetails, signgu } = parseArgs(process.argv.slice(2));
const { job, region, skipDetails, signgu, days } = parseArgs(
process.argv.slice(2),
);
const validSigngu =
signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v));
if (!job || !/^\d{2}$/.test(region) || !validSigngu) {
const validDays = Number.isInteger(days) && days >= 1 && days <= 365;
if (!job || !/^\d{2}$/.test(region) || !validSigngu || !validDays) {
Logger.error(USAGE, 'PlaceSync');
process.exitCode = 1;
return;
@@ -95,6 +112,8 @@ async function main(): Promise<void> {
await app.get(TourPetSync).run();
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'event') {
await app.get(FestivalSync).run({ regionCode: region, days });
} else if (job === 'dedupe') {
await app.get(PlaceDedupe).run();
} else if (job === 'reparse-hours') {
@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFestivalEventSyncColumns1793700318741 implements MigrationInterface {
name = 'AddFestivalEventSyncColumns1793700318741';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source_category" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "event_place" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "ldong_regn_cd" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "ldong_signgu_cd" text`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "phone" text`);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "homepage" text`);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "image_thumb_url" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source_modified_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_region" ON "festival_event" ("ldong_regn_cd") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_region"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "detail_synced_at"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source_modified_at"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "image_thumb_url"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "homepage"`,
);
await queryRunner.query(`ALTER TABLE "festival_event" DROP COLUMN "phone"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "ldong_signgu_cd"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "ldong_regn_cd"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "event_place"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source_category"`,
);
}
}
@@ -0,0 +1,95 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import { EVENT_KINDS, IMAGE_LICENSES } from '../place.constants';
import type { EventKind, ImageLicense } from '../place.constants';
import { PlaceAttributionDto } from './place-detail.dto';
/** 축제·공연·행사 목록의 한 건 */
export class EventListItemDto {
@ApiProperty({ description: '행사 id', example: '7d2e…' })
id!: string;
@ApiProperty({ description: '이름', example: '수성빛예술제' })
name!: string;
@ApiProperty({ description: '분류', enum: EVENT_KINDS, example: 'festival' })
kind!: EventKind;
@ApiProperty({ description: '시작일(YYYY-MM-DD)', example: '2026-11-27' })
startDate!: string;
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-12-06' })
endDate!: string;
@ApiProperty({ description: '오늘(한국 날짜) 진행 중인지', example: false })
ongoing!: boolean;
@ApiProperty({ description: '주소', nullable: true })
address!: string | null;
@ApiProperty({
description: '행사 장소 이름',
nullable: true,
example: '수성못 일원',
})
eventPlace!: string | null;
@ApiProperty({ description: '위도(없을 수 있다)', nullable: true })
lat!: number | null;
@ApiProperty({ description: '경도(없을 수 있다)', nullable: true })
lng!: number | null;
@ApiProperty({
description: '기준 좌표에서의 거리(m). lat·lng 없이 조회하면 null',
nullable: true,
})
distanceM!: number | null;
@ApiProperty({ description: '요금', nullable: true, example: '무료' })
feeText!: string | null;
@ApiProperty({ description: '작은 사진 URL', nullable: true })
imageThumbUrl!: string | null;
@ApiProperty({
description: '사진 공공누리 유형 — 출처 표시 필요(Type3 은 변경 금지)',
enum: IMAGE_LICENSES,
nullable: true,
})
imageLicense!: ImageLicense | null;
}
export class EventListResponseDto {
@ApiProperty({ type: [EventListItemDto] })
items!: EventListItemDto[];
@ApiProperty({ type: PageInfoDto })
pageInfo!: PageInfoDto;
}
/** 축제·공연·행사 상세 */
export class EventDetailDto extends EventListItemDto {
@ApiProperty({ description: '소개', nullable: true })
overview!: string | null;
@ApiProperty({
description: '공연·운영 시간 원문',
nullable: true,
example: '17:00~22:00',
})
playtimeText!: string | null;
@ApiProperty({ description: '홈페이지', nullable: true })
homepage!: string | null;
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -0,0 +1,110 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
Matches,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { EVENT_KINDS } from '../place.constants';
import type { EventKind } from '../place.constants';
/** 기간 기본값·한도(일) */
export const EVENT_RANGE_DEFAULT_DAYS = 30;
export const EVENT_RANGE_MAX_DAYS = 92;
/** 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의) */
export const EVENT_MAX_SPAN_DAYS = 60;
/** 근처 행사 반경(m) */
export const EVENT_RADIUS_DEFAULT_M = 5_000;
export const EVENT_RADIUS_MIN_M = 100;
export const EVENT_RADIUS_MAX_M = 50_000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const toList = ({ value }: { value: unknown }): unknown =>
typeof value === 'string'
? value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: value;
/** 축제·공연·행사 목록 조건 */
export class EventSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '기간 시작(YYYY-MM-DD, 한국 날짜). 생략하면 오늘',
example: '2026-11-07',
})
@IsOptional()
@Matches(DATE_RE, { message: '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.' })
from?: string;
@ApiPropertyOptional({
description: `기간 끝(YYYY-MM-DD). 생략하면 시작일 + ${EVENT_RANGE_DEFAULT_DAYS}일, 최대 ${EVENT_RANGE_MAX_DAYS}일`,
example: '2026-11-08',
})
@IsOptional()
@Matches(DATE_RE, { message: '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.' })
to?: string;
@ApiPropertyOptional({
description:
'분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사',
enum: EVENT_KINDS,
isArray: true,
example: 'festival,event',
})
@IsOptional()
@Transform(toList)
@IsArray({ message: '행사 분류 값이 올바르지 않습니다.' })
@IsIn(EVENT_KINDS, {
each: true,
message: '행사 분류 값이 올바르지 않습니다.',
})
kind?: EventKind[];
@ApiPropertyOptional({
description:
'기준 위도 — lng 와 함께 보내면 반경 안 행사를 거리순으로 준다',
example: 35.8282,
})
@ValidateIf(
(o: EventSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLatitude({ message: '위도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.' })
lat?: number;
@ApiPropertyOptional({ description: '기준 경도', example: 128.6176 })
@ValidateIf(
(o: EventSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLongitude({
message: '경도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.',
})
lng?: number;
@ApiPropertyOptional({
description: `반경(m, ${EVENT_RADIUS_MIN_M}~${EVENT_RADIUS_MAX_M})`,
default: EVENT_RADIUS_DEFAULT_M,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '반경은 정수(m)로 입력해 주세요.' })
@Min(EVENT_RADIUS_MIN_M, {
message: `반경은 ${EVENT_RADIUS_MIN_M}m 이상이어야 합니다.`,
})
@Max(EVENT_RADIUS_MAX_M, {
message: `반경은 ${EVENT_RADIUS_MAX_M}m 이하여야 합니다.`,
})
radius?: number;
}
@@ -26,6 +26,7 @@ import { Place } from './place.entity';
@Entity({ name: 'festival_event' })
@Index('ix_festival_event_period', ['startDate', 'endDate'])
@Index('ix_festival_event_lat_lng', ['lat', 'lng'])
@Index('ix_festival_event_region', ['ldongRegnCd'])
@Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS))
@Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES))
@Check(
@@ -44,6 +45,10 @@ export class FestivalEvent {
@Column({ type: 'text' })
kind!: EventKind;
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제) */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
@Column({ type: 'text' })
name!: string;
@@ -62,6 +67,23 @@ export class FestivalEvent {
@Column({ type: 'text', nullable: true })
address!: string | null;
/** 행사 장소 이름(eventplace, 예: "수성못 일원") */
@Column({ type: 'text', nullable: true })
eventPlace!: string | null;
/** 법정동 시·도(2자리) — 동기화 범위 */
@Column({ type: 'text', nullable: true })
ldongRegnCd!: string | null;
@Column({ type: 'text', nullable: true })
ldongSignguCd!: string | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
/** 개최 장소가 place 에 있으면 연결한다(선택) */
@Column({ type: 'uuid', nullable: true })
placeId!: string | null;
@@ -84,6 +106,9 @@ export class FestivalEvent {
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageThumbUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
@@ -97,6 +122,14 @@ export class FestivalEvent {
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
/** 관광공사 modifiedtime */
@Column({ type: 'timestamptz', nullable: true })
sourceModifiedAt!: Date | null;
/** 상세(소개·요금·공연 시간·장소)를 받은 시각. null 이면 받아야 한다 */
@Column({ type: 'timestamptz', nullable: true })
detailSyncedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@@ -0,0 +1,44 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
EventDetailDto,
EventListResponseDto,
} from './dto/event-list-item.dto';
import { EventSearchQueryDto } from './dto/event-search-query.dto';
import { FestivalService } from './festival.service';
/**
* 축제·공연·행사 조회 — 로그인 없이 쓴다.
* 홈 '이번 주말 축제·행사'(기간), 장소 상세 '근처 축제·행사'(좌표+반경)에 쓴다.
*/
@ApiTags('Event')
@Controller('events')
export class FestivalController {
constructor(private readonly festivalService: FestivalService) {}
@Get()
@ApiOperation({
summary: '축제·공연·행사 목록 (커서 페이지네이션)',
description:
'기간(from~to)과 겹치는 행사를 준다. lat·lng 를 주면 반경 안을 거리순으로, 없으면 시작일순으로 준다. 60일 넘는 상시 프로그램은 뺀다.',
})
@ApiStandardResponse(EventListResponseDto, { description: '행사 목록' })
@ApiErrorResponse('VAL_001', '조회 조건 오류(날짜·기간·좌표·반경·분류·커서)')
search(@Query() query: EventSearchQueryDto) {
return this.festivalService.search(query);
}
@Get(':id')
@ApiOperation({ summary: '축제·공연·행사 상세' })
@ApiStandardResponse(EventDetailDto, { description: '행사 상세' })
@ApiErrorResponse('VAL_001', 'id 형식 오류(UUID 가 아님)')
@ApiErrorResponse('RES_001', '행사를 찾을 수 없음(없거나 숨김)')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.festivalService.findOne(id);
}
}
@@ -0,0 +1,183 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { kstDateString } from '@/shared/utils/kst';
import { EventDetailDto, EventListItemDto } from './dto/event-list-item.dto';
import {
EVENT_MAX_SPAN_DAYS,
EVENT_RADIUS_DEFAULT_M,
EVENT_RANGE_DEFAULT_DAYS,
EVENT_RANGE_MAX_DAYS,
EventSearchQueryDto,
} from './dto/event-search-query.dto';
import { FestivalEvent } from './entities/festival-event.entity';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
const DAY_MS = 86_400_000;
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/** 'YYYY-MM-DD' 두 날짜 사이 일수(b − a). 형식이 틀리거나 없는 날짜면 NaN */
function daysBetween(a: string, b: string): number {
const ta = Date.parse(`${a}T00:00:00Z`);
const tb = Date.parse(`${b}T00:00:00Z`);
const validA = new Date(ta).toISOString().startsWith(a);
const validB = new Date(tb).toISOString().startsWith(b);
return validA && validB ? Math.round((tb - ta) / DAY_MS) : Number.NaN;
}
function addDays(iso: string, days: number): string {
return new Date(Date.parse(`${iso}T00:00:00Z`) + days * DAY_MS)
.toISOString()
.slice(0, 10);
}
/**
* 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'.
* 60일 넘는 상시 프로그램과 숨김 행사는 목록에서 뺀다.
*/
@Injectable()
export class FestivalService {
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
) {}
async search(
query: EventSearchQueryDto,
now = new Date(),
): Promise<PaginatedResult<EventListItemDto>> {
const today = kstDateString(now);
const from = query.from ?? today;
const to = query.to ?? addDays(from, EVENT_RANGE_DEFAULT_DAYS);
const span = daysBetween(from, to);
if (!(span >= 0 && span <= EVENT_RANGE_MAX_DAYS)) {
throw new BusinessException('VAL_001', {
context: { reason: '기간 오류', from, to },
});
}
const near =
query.lat !== undefined && query.lng !== undefined
? {
lat: query.lat,
lng: query.lng,
radius: query.radius ?? EVENT_RADIUS_DEFAULT_M,
}
: null;
const cursor = query.cursor
? decodePlaceCursor(query.cursor, near ? 'number' : 'string')
: null;
if (query.cursor && !cursor) {
throw new BusinessException('VAL_001', {
context: { reason: '커서 형식 오류' },
});
}
const qb = this.events
.createQueryBuilder('e')
.where('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :from AND e.start_date <= :to', { from, to })
.andWhere('(e.end_date - e.start_date) <= :maxSpan', {
maxSpan: EVENT_MAX_SPAN_DAYS,
});
if (query.kind?.length) {
qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind });
}
// 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다
let sortKey = '(e.start_date)';
if (near) {
sortKey = sqlDistanceM(':lat', ':lng', 'e.lat', 'e.lng');
const box = boundingBox(near.lat, near.lng, near.radius);
qb.andWhere('e.lat BETWEEN :latMin AND :latMax', box)
.andWhere('e.lng BETWEEN :lngMin AND :lngMax', box)
.andWhere(`${sortKey} <= :radius`)
.setParameters({ lat: near.lat, lng: near.lng, radius: near.radius });
}
const total = await qb.clone().getCount();
if (cursor) {
const ck = near ? ':ck' : 'CAST(:ck AS date)';
qb.andWhere(`(${sortKey}, e.id) > (${ck}, :cid)`, {
ck: cursor.k,
cid: cursor.id,
});
}
const { entities, raw } = await qb
.addSelect(sortKey, 'sort_key')
.orderBy('sort_key', 'ASC')
.addOrderBy('e.id', 'ASC')
.limit(query.limit + 1)
.getRawAndEntities<{ sort_key: number | string }>();
const hasNext = entities.length > query.limit;
const page = entities.slice(0, query.limit);
const distanceAt = (i: number) => (near ? Number(raw[i]?.sort_key) : null);
const items = page.map((e, i) => this.toListItem(e, distanceAt(i), today));
const last = page.at(-1);
const nextCursor =
hasNext && last
? encodePlaceCursor({
k: near ? (distanceAt(page.length - 1) ?? 0) : last.startDate,
id: last.id,
})
: null;
return toPaginatedResult(items, { hasNext, nextCursor, total });
}
async findOne(id: string, now = new Date()): Promise<EventDetailDto> {
const event = await this.events.findOneBy({ id });
if (!event || event.status !== 'active') {
throw new BusinessException('RES_001', { context: { eventId: id } });
}
return {
...this.toListItem(event, null, kstDateString(now)),
overview: event.overview,
playtimeText: event.playtimeText,
homepage: event.homepage,
phone: event.phone,
imageUrl: event.imageUrl,
attribution: {
provider: '한국관광공사',
asOf: event.sourceModifiedAt
? new Date(event.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
.slice(0, 10)
: null,
},
};
}
private toListItem(
e: FestivalEvent,
distance: number | null,
today: string,
): EventListItemDto {
return {
id: e.id,
name: e.name,
kind: e.kind,
startDate: e.startDate,
endDate: e.endDate,
ongoing: e.startDate <= today && today <= e.endDate,
address: e.address,
eventPlace: e.eventPlace,
lat: e.lat,
lng: e.lng,
distanceM:
distance === null || !Number.isFinite(distance)
? null
: Math.round(distance),
feeText: e.feeText,
imageThumbUrl: e.imageThumbUrl,
imageLicense: e.imageLicense,
};
}
}
+6 -4
View File
@@ -7,12 +7,14 @@ import { Place } from './entities/place.entity';
import { PlaceI18n } from './entities/place-i18n.entity';
import { PlaceListing } from './entities/place-listing.entity';
import { PlaceSyncRun } from './entities/place-sync-run.entity';
import { FestivalController } from './festival.controller';
import { FestivalService } from './festival.service';
import { PlaceController } from './place.controller';
import { PlaceService } from './place.service';
/**
* 장소·축제 데이터 모듈 (설계: docs/design/place-db.md).
* 조회 API(검색·근처 목록·상세)를 제공한다. 동기화는 CLI 전용 PlaceSyncModule(sync/)이 맡는다.
* 조회 API(장소 검색·근처 목록·상세, 축제·공연·행사 목록·상세)를 제공한다. 동기화는 CLI 전용 PlaceSyncModule(sync/)이 맡는다.
*/
@Module({
imports: [
@@ -25,8 +27,8 @@ import { PlaceService } from './place.service';
PlaceSyncRun,
]),
],
controllers: [PlaceController],
providers: [PlaceService],
exports: [TypeOrmModule, PlaceService],
controllers: [PlaceController, FestivalController],
providers: [PlaceService, FestivalService],
exports: [TypeOrmModule, PlaceService, FestivalService],
})
export class PlaceModule {}
@@ -0,0 +1,113 @@
import {
festivalDetail,
festivalItemToEvent,
toIsoDate,
} from './festival-mapper';
// 2026-10-29 대구 점검에서 본 수성빛예술제를 줄인 것
const item = (over: Record<string, unknown> = {}) => ({
contentid: '2785467',
contenttypeid: '15',
title: '수성빛예술제',
eventstartdate: '20261127',
eventenddate: '20261206',
addr1: '대구광역시 수성구 무학로 42',
addr2: '(두산동)',
mapx: '128.6177',
mapy: '35.8277',
lDongRegnCd: '27',
lDongSignguCd: '260',
lclsSystm1: 'EV',
lclsSystm2: 'EV01',
lclsSystm3: 'EV010200',
cpyrhtDivCd: 'Type3',
firstimage: 'http://tong.visitkorea.or.kr/f.jpg',
firstimage2: 'http://tong.visitkorea.or.kr/f_s.jpg',
tel: '053-666-4842',
modifiedtime: '20261020101500',
...over,
});
describe('toIsoDate', () => {
it("'YYYYMMDD' 를 'YYYY-MM-DD' 로 바꾸고, 없는 날짜는 null", () => {
expect(toIsoDate('20261127')).toBe('2026-11-27');
expect(toIsoDate('20260230')).toBeNull();
expect(toIsoDate('2026-11-27')).toBeNull();
expect(toIsoDate(null)).toBeNull();
});
});
describe('festivalItemToEvent', () => {
it('목록 항목을 행 컬럼으로 바꾼다(EV01 → festival)', () => {
expect(festivalItemToEvent(item())).toEqual({
sourceId: '2785467',
kind: 'festival',
sourceCategory: 'EV010200',
name: '수성빛예술제',
startDate: '2026-11-27',
endDate: '2026-12-06',
lat: 35.8277,
lng: 128.6177,
address: '대구광역시 수성구 무학로 42 (두산동)',
ldongRegnCd: '27',
ldongSignguCd: '260',
phone: '053-666-4842',
imageUrl: 'http://tong.visitkorea.or.kr/f.jpg',
imageThumbUrl: 'http://tong.visitkorea.or.kr/f_s.jpg',
imageLicense: 'Type3',
sourceModifiedAt: new Date('2026-10-20T01:15:00.000Z'),
});
});
it('공연·행사 분류와, 분류가 비면 행사로 본다', () => {
expect(festivalItemToEvent(item({ lclsSystm2: 'EV02' }))).toMatchObject({
kind: 'performance',
});
expect(
festivalItemToEvent(item({ lclsSystm2: '', lclsSystm3: '' })),
).toMatchObject({ kind: 'event', sourceCategory: null });
});
it('좌표가 없어도 행사는 남기고 좌표만 비운다', () => {
expect(festivalItemToEvent(item({ mapx: '', mapy: '' }))).toMatchObject({
lat: null,
lng: null,
});
});
it('날짜가 없거나 끝이 시작보다 빠르면 건너뛴다', () => {
expect(festivalItemToEvent(item({ eventenddate: '' }))).toEqual({
skip: 'bad_dates',
});
expect(
festivalItemToEvent(
item({ eventstartdate: '20261210', eventenddate: '20261201' }),
),
).toEqual({ skip: 'bad_dates' });
});
});
describe('festivalDetail', () => {
it('요금은 usetimefestival 에서, 행사 홈페이지를 우선해 읽는다', () => {
expect(
festivalDetail(
{
overview: '수성못에서 열리는 빛 축제<br>야간 조명',
homepage: '<a href="https://www.suseong.kr">수성구</a>',
},
{
usetimefestival: '무료',
playtime: '17:00~22:00',
eventplace: '수성못 일원',
eventhomepage: '<a href="https://light.suseong.kr">행사</a>',
},
),
).toEqual({
overview: '수성못에서 열리는 빛 축제\n야간 조명',
homepage: 'https://light.suseong.kr',
feeText: '무료',
playtimeText: '17:00~22:00',
eventPlace: '수성못 일원',
});
});
});
@@ -0,0 +1,123 @@
import { isInKorea } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { parseKstTimestamp } from '@/shared/utils/kst';
import { IMAGE_LICENSES } from '../place.constants';
import type { EventKind, ImageLicense } from '../place.constants';
import type { TourItem } from './tour-api.client';
/**
* 관광공사 축제·공연·행사(searchFestival2·detailIntro2 관광타입 15) → festival_event 컬럼 (순수 함수).
*/
/** lclsSystm2 → 종류. 분류가 비면 행사로 본다 */
const KIND_BY_LCLS2: Record<string, EventKind> = {
EV01: 'festival',
EV02: 'performance',
EV03: 'event',
};
export interface FestivalListEvent {
sourceId: string;
kind: EventKind;
sourceCategory: string | null;
name: string;
startDate: string;
endDate: string;
lat: number | null;
lng: number | null;
address: string | null;
ldongRegnCd: string | null;
ldongSignguCd: string | null;
phone: string | null;
imageUrl: string | null;
imageThumbUrl: string | null;
imageLicense: ImageLicense | null;
sourceModifiedAt: Date | null;
}
export type FestivalSkipReason = 'no_id' | 'bad_dates';
function str(item: TourItem, key: string): string | null {
const v = item[key];
if (typeof v !== 'string' && typeof v !== 'number') return null;
const s = String(v).trim();
return s === '' ? null : s;
}
/** 'YYYYMMDD' → 'YYYY-MM-DD'. 없는 날짜면 null */
export function toIsoDate(value: string | null): string | null {
const m = value ? /^(\d{4})(\d{2})(\d{2})$/.exec(value) : null;
if (!m) return null;
const iso = `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(`${iso}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().startsWith(iso)
? iso
: null;
}
/** 목록 항목 1건 → festival_event 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function festivalItemToEvent(
item: TourItem,
): FestivalListEvent | { skip: FestivalSkipReason } {
const sourceId = str(item, 'contentid');
const name = htmlToText(item.title);
if (!sourceId || !name) return { skip: 'no_id' };
const startDate = toIsoDate(str(item, 'eventstartdate'));
const endDate = toIsoDate(str(item, 'eventenddate'));
if (!startDate || !endDate || endDate < startDate) {
return { skip: 'bad_dates' };
}
const lat = Number(str(item, 'mapy'));
const lng = Number(str(item, 'mapx'));
const hasCoords = isInKorea(lat, lng);
const lcls2 = str(item, 'lclsSystm2');
const license = str(item, 'cpyrhtDivCd');
const address = [str(item, 'addr1'), str(item, 'addr2')]
.filter(Boolean)
.join(' ');
return {
sourceId,
kind: (lcls2 && KIND_BY_LCLS2[lcls2]) || 'event',
sourceCategory: str(item, 'lclsSystm3') ?? lcls2,
name,
startDate,
endDate,
lat: hasCoords ? lat : null,
lng: hasCoords ? lng : null,
address: address === '' ? null : address,
ldongRegnCd: str(item, 'lDongRegnCd'),
ldongSignguCd: str(item, 'lDongSignguCd'),
phone: htmlToText(item.tel),
imageUrl: str(item, 'firstimage'),
imageThumbUrl: str(item, 'firstimage2'),
imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '')
? (license as ImageLicense)
: null,
sourceModifiedAt: parseKstTimestamp(str(item, 'modifiedtime')),
};
}
export interface FestivalDetail {
overview: string | null;
homepage: string | null;
feeText: string | null;
playtimeText: string | null;
eventPlace: string | null;
}
/**
* 공통정보(detailCommon2) + 소개정보(detailIntro2, 관광타입 15) → 상세 컬럼.
* 요금은 이름과 달리 usetimefestival 에 들어 있다. 행사 홈페이지(eventhomepage)가 있으면 그쪽을 쓴다.
*/
export function festivalDetail(
common: TourItem,
intro: TourItem,
): FestivalDetail {
return {
overview: htmlToText(common.overview),
homepage: extractUrl(intro.eventhomepage) ?? extractUrl(common.homepage),
feeText: htmlToText(intro.usetimefestival),
playtimeText: htmlToText(intro.playtime),
eventPlace: htmlToText(intro.eventplace),
};
}
@@ -0,0 +1,356 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThanOrEqual, Repository } from 'typeorm';
import { kstDateString } from '@/shared/utils/kst';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import {
FestivalListEvent,
FestivalSkipReason,
festivalDetail,
festivalItemToEvent,
} from './festival-mapper';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
/** 오늘부터 며칠 안에 열리는 행사를 받을지 */
export const DEFAULT_EVENT_LOOKAHEAD_DAYS = 90;
/** 관광공사 축제·공연·행사 관광타입 */
const FESTIVAL_TYPE_ID = '15';
const PAGE_SIZE = 500; // 2026-10 점검에서 500 이 동작함
const MAX_PAGES = 20;
const UPSERT_CHUNK = 200;
const MAX_CONSECUTIVE_FAILURES = 10;
/** upsert 때 덮어쓰는 컬럼(상세 컬럼은 상세를 받을 때만 쓴다) */
const OVERWRITE_PROPS = [
'kind',
'sourceCategory',
'name',
'startDate',
'endDate',
'lat',
'lng',
'address',
'ldongRegnCd',
'ldongSignguCd',
'phone',
'imageUrl',
'imageThumbUrl',
'imageLicense',
'sourceModifiedAt',
'status',
'lastSeenAt',
'detailSyncedAt',
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason = FestivalSkipReason | 'other_region';
export interface FestivalSyncResult {
window: { from: string; to: string };
fetched: number;
totalCount: number;
inserted: number;
updated: number;
hidden: number;
skipped: Partial<Record<SkipReason, number>>;
detailed: number;
detailFailed: number;
detailRemaining: number;
stoppedByBudget: boolean;
calls: Record<string, number>;
errors: string[];
}
const toYmd = (iso: string) => iso.replace(/-/g, '');
/**
* 관광공사 축제·공연·행사 동기화(매일). 오늘부터 N일 안에 열리는 지역 행사를 받아 festival_event 에 upsert 한다.
* searchFestival2 는 기간이 겹치는 행사를 준다(종료일 ≥ 요청 시작일). 상세는 새로 생기거나 바뀐 행사만 받는다.
* "60일 넘는 상시 프로그램 제외"는 저장할 때가 아니라 조회 API 에서 적용한다.
*/
@Injectable()
export class FestivalSync {
private readonly logger = new Logger(FestivalSync.name);
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
async run(options: {
regionCode: string;
days?: number;
}): Promise<FestivalSyncResult> {
const { regionCode } = options;
const now = new Date();
const window = {
from: kstDateString(now),
to: kstDateString(now, options.days ?? DEFAULT_EVENT_LOOKAHEAD_DAYS),
};
const budget = new CallBudget(
this.apiFactory.tourDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createTour(budget);
const run = await this.recorder.start('event', regionCode);
const result: FestivalSyncResult = {
window,
fetched: 0,
totalCount: 0,
inserted: 0,
updated: 0,
hidden: 0,
skipped: {},
detailed: 0,
detailFailed: 0,
detailRemaining: 0,
stoppedByBudget: false,
calls: {},
errors: [],
};
try {
await this.syncList(api, regionCode, window, result);
if (!result.stoppedByBudget) {
await this.syncDetails(api, budget, regionCode, window.from, result);
}
result.detailRemaining = await this.events.countBy({
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
endDate: MoreThanOrEqual(window.from),
});
} catch (error) {
result.errors.unshift(
error instanceof Error ? error.message : String(error),
);
throw error;
} finally {
result.calls = budget.spent();
await this.recorder.finish(run, {
fetched: result.fetched,
inserted: result.inserted,
updated: result.updated,
hidden: result.hidden,
calls: result.calls,
errors: result.errors,
});
this.report(result);
}
return result;
}
private async syncList(
api: TourApi,
regionCode: string,
window: { from: string; to: string },
result: FestivalSyncResult,
): Promise<void> {
const runStart = new Date();
const existing = await this.events.find({
select: {
id: true,
sourceId: true,
sourceModifiedAt: true,
detailSyncedAt: true,
status: true,
ldongRegnCd: true,
startDate: true,
endDate: true,
},
});
const bySourceId = new Map(existing.map((e) => [e.sourceId, e]));
const rows: Array<FestivalListEvent & Partial<FestivalEvent>> = [];
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let items: TourItem[];
try {
const res = await api.get('searchFestival2', {
eventStartDate: toYmd(window.from),
eventEndDate: toYmd(window.to),
lDongRegnCd: regionCode,
numOfRows: PAGE_SIZE,
pageNo: page,
arrange: 'A',
});
items = res.items;
result.totalCount = res.totalCount;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
throw error;
}
result.fetched += items.length;
for (const item of items) {
const mapped = festivalItemToEvent(item);
if ('skip' in mapped) {
result.skipped[mapped.skip] = (result.skipped[mapped.skip] ?? 0) + 1;
continue;
}
// 지역 조건을 서버가 무시하더라도 다른 지역 행사는 넣지 않는다
if (mapped.ldongRegnCd && mapped.ldongRegnCd !== regionCode) {
result.skipped.other_region = (result.skipped.other_region ?? 0) + 1;
continue;
}
const prev = bySourceId.get(mapped.sourceId);
const changed =
!prev ||
prev.sourceModifiedAt?.getTime() !==
mapped.sourceModifiedAt?.getTime();
rows.push({
...mapped,
ldongRegnCd: mapped.ldongRegnCd ?? regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
detailSyncedAt: changed ? null : (prev?.detailSyncedAt ?? null),
});
if (prev) result.updated += 1;
else result.inserted += 1;
}
if (items.length === 0 || result.fetched >= result.totalCount) {
complete = true;
break;
}
}
await this.upsert(rows);
if (!complete) return;
// 받은 기간과 겹치는데 이번 목록에 없는 행사는 숨긴다(취소·비공개 전환)
const seen = new Set(rows.map((r) => r.sourceId));
const inWindowBefore = existing.filter(
(e) =>
e.status === 'active' &&
e.ldongRegnCd === regionCode &&
e.endDate >= window.from &&
e.startDate <= window.to,
);
const missing = inWindowBefore.filter((e) => !seen.has(e.sourceId)).length;
if (isSuspiciousRemoval(inWindowBefore.length, missing)) {
result.errors.push(
`기간 안 행사 ${inWindowBefore.length}건 중 ${missing}건이 목록에 없어 원본 장애로 보고 숨김 처리를 건너뜀`,
);
return;
}
const hidden = await this.events
.createQueryBuilder()
.update(FestivalEvent)
.set({ status: 'hidden' })
.where('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
result.hidden = hidden.affected ?? 0;
}
private async upsert(rows: Partial<FestivalEvent>[]): Promise<void> {
const overwrite = OVERWRITE_PROPS.map(
(prop) =>
this.events.metadata.findColumnWithPropertyName(prop)!.databaseName,
);
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
await this.events
.createQueryBuilder()
.insert()
.into(FestivalEvent)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source_id'])
.execute();
}
}
private async syncDetails(
api: TourApi,
budget: CallBudget,
regionCode: string,
today: string,
result: FestivalSyncResult,
): Promise<void> {
const pending = await this.events.find({
where: {
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
endDate: MoreThanOrEqual(today),
},
select: { id: true, sourceId: true },
order: { startDate: 'ASC' },
});
let consecutiveFailures = 0;
for (const event of pending) {
if (
!budget.canSpend('detailCommon2') ||
!budget.canSpend('detailIntro2')
) {
result.stoppedByBudget = true;
break;
}
try {
const common = await api.get('detailCommon2', {
contentId: event.sourceId,
});
const intro = await api.get('detailIntro2', {
contentId: event.sourceId,
contentTypeId: FESTIVAL_TYPE_ID,
});
await this.events.update(event.id, {
...festivalDetail(common.items[0] ?? {}, intro.items[0] ?? {}),
detailSyncedAt: new Date(),
});
result.detailed += 1;
consecutiveFailures = 0;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
result.detailFailed += 1;
consecutiveFailures += 1;
result.errors.push(
`${event.sourceId}: ${error instanceof Error ? error.message : String(error)}`,
);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
private report(r: FestivalSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`축제·행사(${r.window.from} ~ ${r.window.to}) ${r.fetched}/${r.totalCount}건 → 새 행사 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` +
(skipped ? ` · 건너뜀(${skipped})` : ''),
);
this.logger.log(
`상세 ${r.detailed}건 받음 · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` +
(r.stoppedByBudget
? ' — 오늘 한도에 닿아 멈춤, 내일 다시 실행하면 이어서 받는다'
: ''),
);
this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
}
}
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FestivalEvent } from '../entities/festival-event.entity';
import { PlaceCuration } from '../entities/place-curation.entity';
import { Place } from '../entities/place.entity';
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
import { FestivalSync } from './festival.sync';
import { PlaceDedupe } from './place-dedupe';
import { PlaceHoursReparse } from './place-hours-reparse';
import { PlaceSyncStatus } from './place-sync-status';
@@ -18,19 +20,28 @@ import { TourPetSync } from './tour-pet.sync';
* API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
*/
@Module({
imports: [TypeOrmModule.forFeature([Place, PlaceCuration, PlaceSyncRun])],
imports: [
TypeOrmModule.forFeature([
Place,
PlaceCuration,
PlaceSyncRun,
FestivalEvent,
]),
],
providers: [
PublicDataApiFactory,
SyncRunRecorder,
TourKorSync,
TourPetSync,
StoreSync,
FestivalSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
],
exports: [
TourKorSync,
FestivalSync,
TourPetSync,
StoreSync,
PlaceDedupe,
+16 -1
View File
@@ -1,4 +1,9 @@
import { kstClock, kstStartOfDay, parseKstTimestamp } from './kst';
import {
kstClock,
kstDateString,
kstStartOfDay,
parseKstTimestamp,
} from './kst';
describe('kstStartOfDay', () => {
it('한국 날짜의 0시를 돌려준다', () => {
@@ -46,3 +51,13 @@ describe('kstClock', () => {
});
});
});
describe('kstDateString', () => {
it('한국 날짜를 돌려주고 날짜를 더할 수 있다', () => {
// 2026-11-02 23:30 UTC = 2026-11-03 08:30 KST
const now = new Date('2026-11-02T23:30:00Z');
expect(kstDateString(now)).toBe('2026-11-03');
expect(kstDateString(now, 90)).toBe('2027-02-01');
expect(kstDateString(now, -1)).toBe('2026-11-02');
});
});
+6
View File
@@ -43,3 +43,9 @@ export function kstClock(now: Date): { weekday: number; minutes: number } {
minutes: kst.getUTCHours() * 60 + kst.getUTCMinutes(),
};
}
/** 한국 날짜 'YYYY-MM-DD' — addDays 만큼 더하거나 뺀 날 */
export function kstDateString(now: Date, addDays = 0): string {
const kst = new Date(now.getTime() + KST_OFFSET_MS + addDays * 86_400_000);
return kst.toISOString().slice(0, 10);
}
+226
View File
@@ -0,0 +1,226 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
/**
* 축제·공연·행사 조회 API 계약 — 기간 겹침·상시 프로그램 제외·숨김 제외·근처 거리순·분류·커서·상세를 실제 DB 로 검증한다.
* 실행 날짜와 무관하게 2030년 날짜·동해 좌표에 테스트 행사를 만든다(source_id 'e2e-ev-').
*/
const C = { lat: 36.6, lng: 130.6 };
const at = (dLat: number | null) =>
dLat === null ? { lat: null, lng: null } : { lat: C.lat + dLat, lng: C.lng };
const may = 'from=2030-05-01&to=2030-05-31';
const near = `${may}&lat=${C.lat}&lng=${C.lng}&radius=5000`;
interface ListBody {
data: {
items: Array<{
id: string;
name: string;
distanceM: number | null;
ongoing: boolean;
}>;
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
};
error?: { code: string };
}
interface DetailBody {
data: {
id: string;
name: string;
kind: string;
feeText: string | null;
playtimeText: string | null;
eventPlace: string | null;
attribution: { provider: string; asOf: string | null };
};
error?: { code: string };
}
const list = (res: Response) => res.body as ListBody;
const detail = (res: Response) => res.body as DetailBody;
const names = (res: Response) => list(res).data.items.map((i) => i.name);
describe('축제·행사 조회 API (e2e)', () => {
let app: INestApplication<App>;
let ds: DataSource;
const ids: Record<string, string> = {};
const cleanup = () =>
ds.query(`DELETE FROM festival_event WHERE source_id LIKE 'e2e-ev-%'`);
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
await app.init();
ds = app.get(DataSource);
await ds.runMigrations();
await cleanup();
const repo = ds.getRepository(FestivalEvent);
const save = async (key: string, e: Partial<FestivalEvent>) => {
const saved = await repo.save(
repo.create({ sourceId: `e2e-ev-${key}`, ldongRegnCd: '99', ...e }),
);
ids[key] = saved.id;
};
await save('E1', {
kind: 'festival',
name: '이투이 봄꽃축제',
startDate: '2030-05-03',
endDate: '2030-05-05',
...at(0.001),
feeText: '무료',
playtimeText: '10:00~21:00',
eventPlace: '이투이 공원 일원',
sourceModifiedAt: new Date('2030-04-01T00:00:00Z'),
});
await save('E2', {
kind: 'performance',
name: '이투이 재즈공연',
startDate: '2030-05-10',
endDate: '2030-05-10',
...at(0.002),
});
await save('E3', {
kind: 'event',
name: '이투이 상시전시',
startDate: '2030-01-01',
endDate: '2030-12-31', // 60일 넘는 상시 프로그램 — 목록에서 뺀다
...at(0.0015),
});
await save('E4', {
kind: 'festival',
name: '이투이 취소축제',
startDate: '2030-05-04',
endDate: '2030-05-04',
...at(0.0012),
status: 'hidden',
});
await save('E5', {
kind: 'festival',
name: '이투이 먼축제',
startDate: '2030-05-06',
endDate: '2030-05-07',
...at(0.2), // 약 22km — 반경 5km 밖
});
await save('E6', {
kind: 'festival',
name: '이투이 여름축제',
startDate: '2030-06-15',
endDate: '2030-06-16', // 기간 밖
...at(0.001),
});
await save('E7', {
kind: 'event',
name: '이투이 좌표없는행사',
startDate: '2030-05-20',
endDate: '2030-05-21',
...at(null),
});
});
afterAll(async () => {
await cleanup();
await app.close();
});
const get = (path: string) => request(app.getHttpServer()).get(path);
describe('GET /api/events', () => {
it('기간과 겹치는 행사를 시작일순으로 주고, 상시 프로그램·숨김·기간 밖은 뺀다', async () => {
const res = await get(`/api/events?${may}`).expect(200);
expect(names(res)).toEqual([
'이투이 봄꽃축제',
'이투이 먼축제',
'이투이 재즈공연',
'이투이 좌표없는행사',
]);
expect(list(res).data.pageInfo.total).toBe(4);
expect(list(res).data.items.every((i) => i.ongoing === false)).toBe(true);
});
it('좌표를 주면 반경 안 행사를 거리순으로 준다(좌표 없는 행사는 빠진다)', async () => {
const res = await get(`/api/events?${near}`).expect(200);
expect(names(res)).toEqual(['이투이 봄꽃축제', '이투이 재즈공연']);
expect(list(res).data.items[0].distanceM).toBeGreaterThan(100);
});
it('분류 필터', async () => {
const res = await get(`/api/events?${may}&kind=performance`).expect(200);
expect(names(res)).toEqual(['이투이 재즈공연']);
});
it('커서로 이어 받으면 겹치거나 빠지는 행사가 없다', async () => {
const p1 = await get(`/api/events?${may}&limit=2`).expect(200);
expect(names(p1)).toEqual(['이투이 봄꽃축제', '이투이 먼축제']);
const cursor = list(p1).data.pageInfo.nextCursor ?? '';
const p2 = await get(
`/api/events?${may}&limit=2&cursor=${encodeURIComponent(cursor)}`,
).expect(200);
expect(names(p2)).toEqual(['이투이 재즈공연', '이투이 좌표없는행사']);
expect(list(p2).data.pageInfo.hasNext).toBe(false);
});
it.each([
['끝이 시작보다 빠른 기간', 'from=2030-05-31&to=2030-05-01'],
['92일을 넘는 기간', 'from=2030-01-01&to=2030-06-30'],
['형식이 틀린 날짜', 'from=2030/05/01'],
['없는 날짜', 'from=2030-02-30&to=2030-03-01'],
['없는 분류', `${may}&kind=parade`],
['lat 만 보낸 경우', `${may}&lat=36.6`],
])('%s 는 VAL_001(400)', async (_, query) => {
const res = await get(`/api/events?${query}`).expect(400);
expect(list(res).error?.code).toBe('VAL_001');
});
});
describe('GET /api/events/:id', () => {
it('행사 상세 — 요금·공연 시간·장소·출처', async () => {
const res = await get(`/api/events/${ids.E1}`).expect(200);
expect(detail(res).data).toMatchObject({
name: '이투이 봄꽃축제',
kind: 'festival',
feeText: '무료',
playtimeText: '10:00~21:00',
eventPlace: '이투이 공원 일원',
attribution: { provider: '한국관광공사', asOf: '2030-04-01' },
});
});
it('숨김 행사·없는 id 는 RES_001(404), UUID 가 아니면 VAL_001(400)', async () => {
const hidden = await get(`/api/events/${ids.E4}`).expect(404);
expect(detail(hidden).error?.code).toBe('RES_001');
await get('/api/events/00000000-0000-4000-8000-000000000000').expect(404);
const bad = await get('/api/events/abc').expect(400);
expect(detail(bad).error?.code).toBe('VAL_001');
});
});
});