From d3d66444bcdd792dacbb782db43881e28f334ed4 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 22:01:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=ED=96=89=EC=82=AC=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EA=B5=AC=EB=B6=84=C2=B7=EC=A0=84=EC=8B=9C=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=C2=B7=EA=B3=B5=EC=97=B0=EC=9E=A5=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KOPIS 공연·한국문화정보원 전시를 관광공사 행사와 같은 테이블에 받으려면 관광공사 contentid 단독 유니크로는 출처가 섞인다. (source, source_id) 로 바꾸고, KOPIS 공연은 좌표가 공연장 상세에만 있어 event_venue 에 따로 둔다. - 전시는 몇 달씩 하는 것이 보통이라 상시 프로그램 기간 상한을 종류별로 나눔(전시 183일) - 관광공사 동기화는 관광공사 행사만 숨기도록 범위를 좁힘 - 조회 API 에 장르·예매 링크·출처별 표기 추가, 없는 날짜(2030-13-01)가 500 이 되던 것을 VAL_001 로 Co-Authored-By: Claude Opus 5.5 --- .../1793704967449-AddEventSourceAndVenue.ts | 92 +++++++++++++++++++ .../modules/place/dto/event-list-item.dto.ts | 10 ++ .../place/dto/event-search-query.dto.ts | 4 +- .../place/entities/event-venue.entity.ts | 60 ++++++++++++ .../place/entities/festival-event.entity.ts | 42 +++++++-- backend/src/modules/place/festival.service.ts | 27 +++++- backend/src/modules/place/place.constants.ts | 29 +++++- backend/src/modules/place/place.module.ts | 2 + .../src/modules/place/sync/festival.sync.ts | 10 +- .../modules/place/sync/place-sync.module.ts | 4 +- backend/test/festival.e2e-spec.ts | 30 +++++- 11 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 backend/src/migrations/1793704967449-AddEventSourceAndVenue.ts create mode 100644 backend/src/modules/place/entities/event-venue.entity.ts diff --git a/backend/src/migrations/1793704967449-AddEventSourceAndVenue.ts b/backend/src/migrations/1793704967449-AddEventSourceAndVenue.ts new file mode 100644 index 0000000..e4b8556 --- /dev/null +++ b/backend/src/migrations/1793704967449-AddEventSourceAndVenue.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 행사 출처를 여럿 받을 준비(KOPIS 공연·한국문화정보원 전시 등): + * - festival_event.source + (source, source_id) 유니크 — 관광공사 contentid 단독 유니크였다 + * - 장르·예매 링크·공연장(event_venue) — KOPIS 공연은 좌표가 공연장 상세에만 있다 + * - 행사 종류에 'exhibition', 동기화 작업에 'kopis'·'kcisa' 추가 + * + * ⚠️ CHECK 제약 변경(ck_festival_event_kind·ck_place_sync_run_job)은 migration:generate 가 감지하지 못해 직접 적었다. + * 기존 행사는 기본값으로 source='tour' 가 된다. + */ +export class AddEventSourceAndVenue1793704967449 implements MigrationInterface { + name = 'AddEventSourceAndVenue1793704967449'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "event_venue" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "name" text NOT NULL, "address" text, "lat" double precision, "lng" double precision, "phone" text, "homepage" text, "synced_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_event_venue_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_event_venue_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca')), CONSTRAINT "PK_128347780df4ef90d1426da6c77" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD "source" text NOT NULL DEFAULT 'tour'`, + ); + await queryRunner.query(`ALTER TABLE "festival_event" ADD "genre" text`); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD "booking_url" text`, + ); + await queryRunner.query(`ALTER TABLE "festival_event" ADD "venue_id" uuid`); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca'))`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD CONSTRAINT "uq_festival_event_source" UNIQUE ("source", "source_id")`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD CONSTRAINT "FK_93324676cbe20bf271722f548b0" FOREIGN KEY ("venue_id") REFERENCES "event_venue"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + // 직접 적은 CHECK 제약 변경 + await queryRunner.query( + `ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event', 'exhibition'))`, + ); + await queryRunner.query( + `ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`, + ); + await queryRunner.query( + `ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure', 'kopis', 'kcisa'))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // 되돌리기 전에 kopis·kcisa 실행 기록과 전시·다른 출처 행사를 정리해야 한다(제약 위반으로 실패한다) + await queryRunner.query( + `ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`, + ); + await queryRunner.query( + `ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure'))`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event'))`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP CONSTRAINT "FK_93324676cbe20bf271722f548b0"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP CONSTRAINT "uq_festival_event_source"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_source"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" ADD CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id")`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP COLUMN "venue_id"`, + ); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP COLUMN "booking_url"`, + ); + await queryRunner.query(`ALTER TABLE "festival_event" DROP COLUMN "genre"`); + await queryRunner.query( + `ALTER TABLE "festival_event" DROP COLUMN "source"`, + ); + await queryRunner.query(`DROP TABLE "event_venue"`); + } +} diff --git a/backend/src/modules/place/dto/event-list-item.dto.ts b/backend/src/modules/place/dto/event-list-item.dto.ts index a43ea58..d066c3b 100644 --- a/backend/src/modules/place/dto/event-list-item.dto.ts +++ b/backend/src/modules/place/dto/event-list-item.dto.ts @@ -22,6 +22,13 @@ export class EventListItemDto { @ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-12-06' }) endDate!: string; + @ApiProperty({ + description: '장르(연극·뮤지컬·클래식·전시 …)', + nullable: true, + example: '뮤지컬', + }) + genre!: string | null; + @ApiProperty({ description: '오늘(한국 날짜) 진행 중인지', example: false }) ongoing!: boolean; @@ -87,6 +94,9 @@ export class EventDetailDto extends EventListItemDto { @ApiProperty({ description: '전화', nullable: true }) phone!: string | null; + @ApiProperty({ description: '예매·관람 안내 링크', nullable: true }) + bookingUrl!: string | null; + @ApiProperty({ description: '큰 사진 URL', nullable: true }) imageUrl!: string | null; diff --git a/backend/src/modules/place/dto/event-search-query.dto.ts b/backend/src/modules/place/dto/event-search-query.dto.ts index 2eb210b..dec97f3 100644 --- a/backend/src/modules/place/dto/event-search-query.dto.ts +++ b/backend/src/modules/place/dto/event-search-query.dto.ts @@ -20,8 +20,6 @@ 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; @@ -58,7 +56,7 @@ export class EventSearchQueryDto extends CursorPaginationQueryDto { @ApiPropertyOptional({ description: - '분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사', + '분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사 · exhibition 전시', enum: EVENT_KINDS, isArray: true, example: 'festival,event', diff --git a/backend/src/modules/place/entities/event-venue.entity.ts b/backend/src/modules/place/entities/event-venue.entity.ts new file mode 100644 index 0000000..69a8ab9 --- /dev/null +++ b/backend/src/modules/place/entities/event-venue.entity.ts @@ -0,0 +1,60 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import { EVENT_SOURCES, sqlIn } from '../place.constants'; +import type { EventSource } from '../place.constants'; + +/** + * 공연장·전시장 — 공연 목록에는 좌표가 없고 공연장 상세에만 있는 출처(KOPIS 공연시설 mt10id)를 위해 따로 둔다. + * 새 공연장이 나올 때만 상세를 부르고, 한 달에 한 번 갱신한다. 좌표는 시설 단위다(홀이 여러 개여도 하나). + */ +@Entity() +@Unique('uq_event_venue_source', ['source', 'sourceId']) +@Check('ck_event_venue_source', sqlIn('source', EVENT_SOURCES)) +export class EventVenue { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'text' }) + source!: EventSource; + + /** 원본 시설 id — KOPIS mt10id 등 */ + @Column({ type: 'text' }) + sourceId!: string; + + @Column({ type: 'text' }) + name!: string; + + @Column({ type: 'text', nullable: true }) + address!: string | null; + + /** 좌표가 없는 시설도 있다(그 공연은 근처 조회에서만 빠진다) */ + @Column({ type: 'double precision', nullable: true }) + lat!: number | null; + + @Column({ type: 'double precision', nullable: true }) + lng!: number | null; + + @Column({ type: 'text', nullable: true }) + phone!: string | null; + + @Column({ type: 'text', nullable: true }) + homepage!: string | null; + + /** 시설 상세를 받은 시각 */ + @Column({ type: 'timestamptz', nullable: true }) + syncedAt!: Date | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/place/entities/festival-event.entity.ts b/backend/src/modules/place/entities/festival-event.entity.ts index 419f478..9b8760f 100644 --- a/backend/src/modules/place/entities/festival-event.entity.ts +++ b/backend/src/modules/place/entities/festival-event.entity.ts @@ -7,26 +7,37 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, + Unique, UpdateDateColumn, } from 'typeorm'; import { EVENT_KINDS, + EVENT_SOURCES, EVENT_STATUSES, IMAGE_LICENSES, sqlIn, } from '../place.constants'; -import type { EventKind, EventStatus, ImageLicense } from '../place.constants'; +import type { + EventKind, + EventSource, + EventStatus, + ImageLicense, +} from '../place.constants'; +import { EventVenue } from './event-venue.entity'; import { Place } from './place.entity'; /** - * 축제·공연·행사(관광공사 searchFestival2). 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다. - * "60일 넘는 상시 프로그램 제외"는 저장할 때가 아니라 조회할 때 적용한다. + * 축제·공연·행사·전시. 출처는 관광공사(searchFestival2)·KOPIS(공연)·한국문화정보원(전시) 등이다. + * 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다. + * "상시 프로그램 제외"(종류별 기간 상한, EVENT_MAX_SPAN_DAYS)는 저장할 때가 아니라 조회할 때 적용한다. */ @Entity({ name: 'festival_event' }) +@Unique('uq_festival_event_source', ['source', 'sourceId']) @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_source', sqlIn('source', EVENT_SOURCES)) @Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS)) @Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES)) @Check( @@ -38,17 +49,36 @@ export class FestivalEvent { @PrimaryGeneratedColumn('uuid') id!: string; - /** 관광공사 contentid */ - @Column({ type: 'text', unique: true }) + @Column({ type: 'text', default: 'tour' }) + source!: EventSource; + + /** 원본 id — 관광공사 contentid · KOPIS mt20id · 문화정보원 seq. (source, sourceId) 가 유니크 */ + @Column({ type: 'text' }) sourceId!: string; @Column({ type: 'text' }) kind!: EventKind; - /** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제) */ + /** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제 · KOPIS 장르코드 · 문화정보원 realmCode) */ @Column({ type: 'text', nullable: true }) sourceCategory!: string | null; + /** 장르 표시용(연극·뮤지컬·클래식·전시 …) */ + @Column({ type: 'text', nullable: true }) + genre!: string | null; + + /** 예매·관람 안내 링크 */ + @Column({ type: 'text', nullable: true }) + bookingUrl!: string | null; + + /** 공연장·전시장(좌표를 공연장에서 얻는 출처가 있다 — KOPIS 공연시설) */ + @Column({ type: 'uuid', nullable: true }) + venueId!: string | null; + + @ManyToOne(() => EventVenue, { nullable: true }) + @JoinColumn({ name: 'venue_id' }) + venue?: EventVenue | null; + @Column({ type: 'text' }) name!: string; diff --git a/backend/src/modules/place/festival.service.ts b/backend/src/modules/place/festival.service.ts index 7853c1d..93e6c7b 100644 --- a/backend/src/modules/place/festival.service.ts +++ b/backend/src/modules/place/festival.service.ts @@ -10,23 +10,39 @@ 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 { EVENT_MAX_SPAN_DAYS } from './place.constants'; +import type { EventSource } from './place.constants'; 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; +/** 출처 표기(행사 상세 하단·앱 정보 화면) */ +const PROVIDER_LABEL: Record = { + tour: '한국관광공사', + kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)', + kcisa: '한국문화정보원 문화포털', + dgfca: '대구문화예술진흥원', +}; + +/** 종류별 기간 상한 SQL — 상수(EVENT_MAX_SPAN_DAYS)로만 만든다 */ +const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS) + .map(([kind, days]) => `WHEN '${kind}' THEN ${days}`) + .join(' ')} END`; + /** '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`); + // '2030-13-01' 처럼 해석이 안 되면 NaN — toISOString 이 예외를 던지지 않게 먼저 거른다 + if (Number.isNaN(ta) || Number.isNaN(tb)) return Number.NaN; 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; @@ -83,9 +99,8 @@ export class FestivalService { .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, - }); + // 상시 프로그램 제외 — 종류별 기간 상한(전시는 길게) + .andWhere(`(e.end_date - e.start_date) <= ${MAX_SPAN_SQL}`); if (query.kind?.length) { qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind }); } @@ -143,9 +158,10 @@ export class FestivalService { playtimeText: event.playtimeText, homepage: event.homepage, phone: event.phone, + bookingUrl: event.bookingUrl, imageUrl: event.imageUrl, attribution: { - provider: '한국관광공사', + provider: PROVIDER_LABEL[event.source], asOf: event.sourceModifiedAt ? new Date(event.sourceModifiedAt.getTime() + KST_OFFSET_MS) .toISOString() @@ -164,6 +180,7 @@ export class FestivalService { id: e.id, name: e.name, kind: e.kind, + genre: e.genre, startDate: e.startDate, endDate: e.endDate, ongoing: e.startDate <= today && today <= e.endDate, diff --git a/backend/src/modules/place/place.constants.ts b/backend/src/modules/place/place.constants.ts index fb1a297..4a571f5 100644 --- a/backend/src/modules/place/place.constants.ts +++ b/backend/src/modules/place/place.constants.ts @@ -75,11 +75,34 @@ export type I18nOrigin = (typeof I18N_ORIGINS)[number]; export const IMAGE_LICENSES = ['Type1', 'Type3'] as const; export type ImageLicense = (typeof IMAGE_LICENSES)[number]; -/** 관광공사 lclsSystm2: EV01 축제 · EV02 공연 · EV03 행사 */ -export const EVENT_KINDS = ['festival', 'performance', 'event'] as const; +/** 행사 종류 — 관광공사 lclsSystm2(EV01 축제 · EV02 공연 · EV03 행사) + 전시(KOPIS 장르는 genre 에 따로 둔다) */ +export const EVENT_KINDS = [ + 'festival', + 'performance', + 'event', + 'exhibition', +] as const; export type EventKind = (typeof EVENT_KINDS)[number]; export const EVENT_STATUSES = ['active', 'hidden'] as const; + +/** + * 행사 출처 — tour 한국관광공사 · kopis 공연예술통합전산망 · kcisa 한국문화정보원 · dgfca 대구문화예술진흥원. + * (출처, 원본 id) 가 행사·공연장의 연결 키다. + */ +export const EVENT_SOURCES = ['tour', 'kopis', 'kcisa', 'dgfca'] as const; +export type EventSource = (typeof EVENT_SOURCES)[number]; + +/** + * 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의는 60일). + * 전시는 몇 달씩 하는 것이 보통이라 길게 잡는다 — 그대로 두면 전시 대부분이 빠진다. + */ +export const EVENT_MAX_SPAN_DAYS: Record = { + festival: 60, + performance: 60, + event: 60, + exhibition: 183, +}; export type EventStatus = (typeof EVENT_STATUSES)[number]; export const SYNC_JOBS = [ @@ -89,6 +112,8 @@ export const SYNC_JOBS = [ 'store', 'event', 'closure', + 'kopis', + 'kcisa', ] as const; export type SyncJob = (typeof SYNC_JOBS)[number]; diff --git a/backend/src/modules/place/place.module.ts b/backend/src/modules/place/place.module.ts index ebc2ee7..ae92af8 100644 --- a/backend/src/modules/place/place.module.ts +++ b/backend/src/modules/place/place.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventVenue } from './entities/event-venue.entity'; import { FestivalEvent } from './entities/festival-event.entity'; import { PlaceCuration } from './entities/place-curation.entity'; import { Place } from './entities/place.entity'; @@ -24,6 +25,7 @@ import { PlaceService } from './place.service'; PlaceI18n, PlaceListing, FestivalEvent, + EventVenue, PlaceSyncRun, ]), ], diff --git a/backend/src/modules/place/sync/festival.sync.ts b/backend/src/modules/place/sync/festival.sync.ts index 6b2af32..322dd81 100644 --- a/backend/src/modules/place/sync/festival.sync.ts +++ b/backend/src/modules/place/sync/festival.sync.ts @@ -141,6 +141,7 @@ export class FestivalSync { await this.syncDetails(api, budget, regionCode, window.from, result); } result.detailRemaining = await this.events.countBy({ + source: 'tour', ldongRegnCd: regionCode, status: 'active', detailSyncedAt: IsNull(), @@ -173,7 +174,9 @@ export class FestivalSync { result: FestivalSyncResult, ): Promise { const runStart = new Date(); + // 관광공사 행사만 다룬다(KOPIS 공연 등 다른 출처 행사는 각자의 동기화가 관리한다) const existing = await this.events.find({ + where: { source: 'tour' }, select: { id: true, sourceId: true, @@ -229,6 +232,7 @@ export class FestivalSync { mapped.sourceModifiedAt?.getTime(); rows.push({ ...mapped, + source: 'tour', ldongRegnCd: mapped.ldongRegnCd ?? regionCode, status: 'active', lastSeenAt: runStart, @@ -267,7 +271,8 @@ export class FestivalSync { .createQueryBuilder() .update(FestivalEvent) .set({ status: 'hidden' }) - .where('ldong_regn_cd = :region', { region: regionCode }) + .where('source = :source', { source: 'tour' }) + .andWhere('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)', { @@ -288,7 +293,7 @@ export class FestivalSync { .insert() .into(FestivalEvent) .values(rows.slice(i, i + UPSERT_CHUNK)) - .orUpdate(overwrite, ['source_id']) + .orUpdate(overwrite, ['source', 'source_id']) .execute(); } } @@ -302,6 +307,7 @@ export class FestivalSync { ): Promise { const pending = await this.events.find({ where: { + source: 'tour', ldongRegnCd: regionCode, status: 'active', detailSyncedAt: IsNull(), diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts index 8a296fb..0a103c9 100644 --- a/backend/src/modules/place/sync/place-sync.module.ts +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventVenue } from '../entities/event-venue.entity'; import { FestivalEvent } from '../entities/festival-event.entity'; import { PlaceCuration } from '../entities/place-curation.entity'; import { Place } from '../entities/place.entity'; @@ -16,7 +17,7 @@ import { StoreSync } from './store.sync'; import { TourPetSync } from './tour-pet.sync'; /** - * 장소 동기화 — 공공데이터를 place 에 적재한다. + * 장소·행사 동기화 — 공공데이터를 place·festival_event 에 적재한다. * API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다. */ @Module({ @@ -26,6 +27,7 @@ import { TourPetSync } from './tour-pet.sync'; PlaceCuration, PlaceSyncRun, FestivalEvent, + EventVenue, ]), ], providers: [ diff --git a/backend/test/festival.e2e-spec.ts b/backend/test/festival.e2e-spec.ts index c3e46b7..1713fa7 100644 --- a/backend/test/festival.e2e-spec.ts +++ b/backend/test/festival.e2e-spec.ts @@ -145,6 +145,14 @@ describe('축제·행사 조회 API (e2e)', () => { endDate: '2030-05-21', ...at(null), }); + await save('E8', { + kind: 'exhibition', + name: '이투이 기획전시', + startDate: '2030-03-01', + endDate: '2030-07-31', // 5개월 — 전시는 기간 상한이 길어 목록에 남는다 + ...at(0.0025), + genre: '전시', + }); }); afterAll(async () => { @@ -158,32 +166,43 @@ describe('축제·행사 조회 API (e2e)', () => { 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.pageInfo.total).toBe(5); 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(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(['이투이 재즈공연']); + const ex = await get(`/api/events?${may}&kind=exhibition`).expect(200); + expect(names(ex)).toEqual(['이투이 기획전시']); }); it('커서로 이어 받으면 겹치거나 빠지는 행사가 없다', async () => { - const p1 = await get(`/api/events?${may}&limit=2`).expect(200); - expect(names(p1)).toEqual(['이투이 봄꽃축제', '이투이 먼축제']); + const p1 = await get(`/api/events?${may}&limit=3`).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)}`, + `/api/events?${may}&limit=3&cursor=${encodeURIComponent(cursor)}`, ).expect(200); expect(names(p2)).toEqual(['이투이 재즈공연', '이투이 좌표없는행사']); expect(list(p2).data.pageInfo.hasNext).toBe(false); @@ -194,6 +213,7 @@ describe('축제·행사 조회 API (e2e)', () => { ['92일을 넘는 기간', 'from=2030-01-01&to=2030-06-30'], ['형식이 틀린 날짜', 'from=2030/05/01'], ['없는 날짜', 'from=2030-02-30&to=2030-03-01'], + ['해석이 안 되는 날짜(13월)', 'from=2030-13-01&to=2030-12-31'], ['없는 분류', `${may}&kind=parade`], ['lat 만 보낸 경우', `${may}&lat=36.6`], ])('%s 는 VAL_001(400)', async (_, query) => {