diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts index e01c1ab..599810d 100644 --- a/backend/src/commands/place-sync.ts +++ b/backend/src/commands/place-sync.ts @@ -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 { - 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 { 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') { diff --git a/backend/src/migrations/1793700318741-AddFestivalEventSyncColumns.ts b/backend/src/migrations/1793700318741-AddFestivalEventSyncColumns.ts new file mode 100644 index 0000000..07d855d --- /dev/null +++ b/backend/src/migrations/1793700318741-AddFestivalEventSyncColumns.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFestivalEventSyncColumns1793700318741 implements MigrationInterface { + name = 'AddFestivalEventSyncColumns1793700318741'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`, + ); + } +} diff --git a/backend/src/modules/place/dto/event-list-item.dto.ts b/backend/src/modules/place/dto/event-list-item.dto.ts new file mode 100644 index 0000000..a43ea58 --- /dev/null +++ b/backend/src/modules/place/dto/event-list-item.dto.ts @@ -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; +} diff --git a/backend/src/modules/place/dto/event-search-query.dto.ts b/backend/src/modules/place/dto/event-search-query.dto.ts new file mode 100644 index 0000000..8bb28b9 --- /dev/null +++ b/backend/src/modules/place/dto/event-search-query.dto.ts @@ -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; +} diff --git a/backend/src/modules/place/entities/festival-event.entity.ts b/backend/src/modules/place/entities/festival-event.entity.ts index a822437..419f478 100644 --- a/backend/src/modules/place/entities/festival-event.entity.ts +++ b/backend/src/modules/place/entities/festival-event.entity.ts @@ -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; diff --git a/backend/src/modules/place/festival.controller.ts b/backend/src/modules/place/festival.controller.ts new file mode 100644 index 0000000..0d90ec5 --- /dev/null +++ b/backend/src/modules/place/festival.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/place/festival.service.ts b/backend/src/modules/place/festival.service.ts new file mode 100644 index 0000000..7853c1d --- /dev/null +++ b/backend/src/modules/place/festival.service.ts @@ -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, + ) {} + + async search( + query: EventSearchQueryDto, + now = new Date(), + ): Promise> { + 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 { + 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, + }; + } +} diff --git a/backend/src/modules/place/place.module.ts b/backend/src/modules/place/place.module.ts index 4740dd4..ebc2ee7 100644 --- a/backend/src/modules/place/place.module.ts +++ b/backend/src/modules/place/place.module.ts @@ -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 {} diff --git a/backend/src/modules/place/sync/festival-mapper.spec.ts b/backend/src/modules/place/sync/festival-mapper.spec.ts new file mode 100644 index 0000000..6dc3ad9 --- /dev/null +++ b/backend/src/modules/place/sync/festival-mapper.spec.ts @@ -0,0 +1,113 @@ +import { + festivalDetail, + festivalItemToEvent, + toIsoDate, +} from './festival-mapper'; + +// 2026-10-29 대구 점검에서 본 수성빛예술제를 줄인 것 +const item = (over: Record = {}) => ({ + 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: '수성못에서 열리는 빛 축제
야간 조명', + homepage: '수성구', + }, + { + usetimefestival: '무료', + playtime: '17:00~22:00', + eventplace: '수성못 일원', + eventhomepage: '행사', + }, + ), + ).toEqual({ + overview: '수성못에서 열리는 빛 축제\n야간 조명', + homepage: 'https://light.suseong.kr', + feeText: '무료', + playtimeText: '17:00~22:00', + eventPlace: '수성못 일원', + }); + }); +}); diff --git a/backend/src/modules/place/sync/festival-mapper.ts b/backend/src/modules/place/sync/festival-mapper.ts new file mode 100644 index 0000000..b4e6a7a --- /dev/null +++ b/backend/src/modules/place/sync/festival-mapper.ts @@ -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 = { + 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), + }; +} diff --git a/backend/src/modules/place/sync/festival.sync.ts b/backend/src/modules/place/sync/festival.sync.ts new file mode 100644 index 0000000..9d5e51a --- /dev/null +++ b/backend/src/modules/place/sync/festival.sync.ts @@ -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>; + detailed: number; + detailFailed: number; + detailRemaining: number; + stoppedByBudget: boolean; + calls: Record; + 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, + private readonly recorder: SyncRunRecorder, + private readonly apiFactory: PublicDataApiFactory, + ) {} + + async run(options: { + regionCode: string; + days?: number; + }): Promise { + 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 { + 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> = []; + 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[]): Promise { + 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 { + 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); + } +} diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts index 00d6746..8a296fb 100644 --- a/backend/src/modules/place/sync/place-sync.module.ts +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -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, diff --git a/backend/src/shared/utils/kst.spec.ts b/backend/src/shared/utils/kst.spec.ts index d35ead0..1e0d3cc 100644 --- a/backend/src/shared/utils/kst.spec.ts +++ b/backend/src/shared/utils/kst.spec.ts @@ -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'); + }); +}); diff --git a/backend/src/shared/utils/kst.ts b/backend/src/shared/utils/kst.ts index 5829223..b9c58df 100644 --- a/backend/src/shared/utils/kst.ts +++ b/backend/src/shared/utils/kst.ts @@ -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); +} diff --git a/backend/test/festival.e2e-spec.ts b/backend/test/festival.e2e-spec.ts new file mode 100644 index 0000000..c3e46b7 --- /dev/null +++ b/backend/test/festival.e2e-spec.ts @@ -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; + let ds: DataSource; + const ids: Record = {}; + + 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) => { + 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'); + }); + }); +}); diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 633d1af..ce05e4e 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -1,7 +1,7 @@ -# 장소 DB 설계 (v1.4, 2026-11-02) +# 장소 DB 설계 (v1.5, 2026-11-03) WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다. -1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령), 중복 병합, 조회 API(§11)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`. +1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령), 중복 병합, 조회 API(§11), 축제·행사 동기화·API(§12)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`. 이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다. ## 0. 전제 (이미 정한 것) @@ -248,6 +248,7 @@ dc exec backend npm run migration:run # 스키마 먼저 dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 → 상세. 한도에 닿으면 멈춘다 dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회) dc exec backend npm run place:sync -- store # 상가정보 데이트 업종(대구 중구·남구·수성구, 30회 안팎). 분기마다 +dc exec backend npm run place:sync -- event # 축제·공연·행사(대구, 오늘부터 90일 안). 매일. 새로 생기거나 바뀐 행사만 상세 dc exec backend npm run place:sync -- dedupe # 같은 가게 병합(키 불필요). tour-kor·store 가 끝날 때도 자동으로 돈다 dc exec backend npm run place:sync -- reparse-hours # 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요). 파서를 고친 뒤 한 번 dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수 @@ -260,13 +261,14 @@ node dist/commands/place-sync.js tour-kor - 한도: `TOUR_API_DAILY_LIMIT`(비우면 1,000) − 여유분 20. 오늘(KST) `place_sync_run` 에 기록된 호출부터 센다. 같은 키를 점검 스크립트 등 다른 곳에서 쓴 호출은 기록에 없으므로, 서버가 한도 초과(코드 22)로 거부하면 그때 멈춘다. - 상세는 nature → attraction → culture → cafe → food … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터). -- 목록을 끝까지 받았을 때만 사라진 장소를 숨기고, 목록이 기존 노출 장소의 80% 미만이면 원본 장애로 보고 숨기지 않는다. +- 목록을 끝까지 받았을 때만 사라진 장소를 숨긴다. 사라진 수가 5건을 넘고 기존의 20% 도 넘거나, 기존이 전부 사라졌으면 + 원본 장애로 보고 숨기지 않는다(`sync/removal-guard.ts`, 관광공사·상가정보·축제 공통). 비율만 보면 항목이 적은 곳의 정상 취소가 막혀서 바꿨다. - 원본 수정일(modifiedtime)이 바뀐 장소는 `detail_synced_at` 을 비워 상세를 다시 받는다. - 영업시간 해석(2026-11-03 대구 관광공사 1,174곳): 시간표 809 · 상시 개방 210 · 점포별 상이 75 · 영업시간 아닌 문장 20 · 원문 없음(숙소) 60. 원문은 구간이 구분 없이 붙거나("…20:00금요일~일요일 …"), 줄바꿈으로만 나뉘거나, "[토요일]" 머리말 다음 줄에 시간이 오는 등 형식이 제각각이다. 파서를 고친 뒤 `reparse-hours` 로 124곳을 바로잡았다(API 재호출 없음). - 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경). - 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾸고, 결과가 기존의 80% 미만이면 건너뛴다. + 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다). 업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다. - 2026-11-02 임시 DB + 가짜 상가정보 API 로 확인: 첫 적재 → 다음 분기 폐업 추정·업종 변경·신규 → 급감 시 폐업 처리 건너뜀 → 한도 0 이면 아무것도 바꾸지 않음. - 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영. @@ -330,7 +332,7 @@ node dist/commands/place-sync.js tour-kor 2. ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일) 3. ✅ 상가정보 적재 명령(대구 중구·남구·수성구 데이트 업종) (2026-11-02) 4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영) -5. ✅ 조회 API: `GET /api/places`(검색·근처·필터·커서), `GET /api/places/{id}` + 중복 병합 (2026-11-02). `GET /api/events` 는 축제 동기화와 함께 +5. ✅ 조회 API: `GET /api/places`(검색·근처·필터·커서), `GET /api/places/{id}` + 중복 병합 (2026-11-02), 축제 `GET /api/events`·`/{id}` + 축제 동기화 (2026-11-03) 6. 일·주·분기 동기화 + 폐업 대조 ## 11. 조회 API (구현됨) @@ -346,4 +348,26 @@ node dist/commands/place-sync.js tour-kor - 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제). - 실패 코드: 검색 조건·커서·id 형식 오류 VAL_001(400), 없음 RES_001(404). 새 코드는 만들지 않았다. - 계약 테스트: `backend/test/place.e2e-spec.ts` — 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 검증한다(16개). -- 아직 없는 것: 영어 등 다국어 응답(`place_i18n`), 지도 화면용 사각형(bbox) 조회, 축제 API. +- 아직 없는 것: 영어 등 다국어 응답(`place_i18n`), 지도 화면용 사각형(bbox) 조회. + +## 12. 축제·공연·행사 (구현됨) + +**동기화** `place:sync -- event [--region=27] [--days=90]` (매일) + +- `searchFestival2` 를 오늘~N일 기간으로 부른다. 서버는 기간이 겹치는 행사를 준다(종료일 ≥ 요청 시작일). +- 지역 조건(`lDongRegnCd`)을 보내고, 응답에서도 다른 지역 행사는 한 번 더 거른다(서버가 조건을 무시해도 섞이지 않게). +- 종류: lclsSystm2 EV01 축제 → `festival`, EV02 공연 → `performance`, EV03 행사 → `event`(분류가 비면 행사). +- 상세(`detailCommon2` 소개 + `detailIntro2` 관광타입 15)는 새로 생기거나 수정일이 바뀐 행사만 받는다(`detail_synced_at`). + 요금은 이름과 달리 `usetimefestival`, 공연 시간 `playtime`, 장소 `eventplace`, 홈페이지는 `eventhomepage` 우선. +- 좌표가 없는 행사도 저장한다(근처 조회에서만 빠진다). 받은 기간 안에서 목록에 없는 행사는 숨긴다(원본 장애 판단은 §4.1). +- 2026-11-03 임시 DB + 가짜 API 로 확인: 다른 지역·날짜 없는 행사 건너뜀 → 재실행 시 상세 재조회 없음 → 수정 1건만 상세 재조회, 취소 1건 숨김 → 빈 응답이면 숨기지 않음. + +**API** + +| 엔드포인트 | 내용 | +| --- | --- | +| `GET /api/events` | `from`·`to`(YYYY-MM-DD, 기본 오늘~30일, 최대 92일) · `lat`+`lng`+`radius`(기본 5km, 최대 50km) · `kind` · `cursor` · `limit`. 기간과 겹치는 행사를, 좌표가 있으면 거리순·없으면 시작일순으로. **60일 넘는 상시 프로그램과 숨김 행사는 뺀다.** 항목에 `ongoing`(오늘 진행 중) | +| `GET /api/events/{id}` | 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001 | + +- 화면 쓰임: 홈 '이번 주말 축제·행사' = 토·일 날짜로 `from`·`to`, 장소 상세 '근처 축제·행사' = 장소 좌표 + 반경. +- 계약 테스트: `backend/test/festival.e2e-spec.ts`(12개) — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.