feat(backend): 행사 출처 구분·전시 종류·공연장 테이블 추가

KOPIS 공연·한국문화정보원 전시를 관광공사 행사와 같은 테이블에 받으려면
관광공사 contentid 단독 유니크로는 출처가 섞인다. (source, source_id) 로 바꾸고,
KOPIS 공연은 좌표가 공연장 상세에만 있어 event_venue 에 따로 둔다.

- 전시는 몇 달씩 하는 것이 보통이라 상시 프로그램 기간 상한을 종류별로 나눔(전시 183일)
- 관광공사 동기화는 관광공사 행사만 숨기도록 범위를 좁힘
- 조회 API 에 장르·예매 링크·출처별 표기 추가, 없는 날짜(2030-13-01)가 500 이 되던 것을 VAL_001 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 22:01:23 +09:00
parent 009a287533
commit d3d66444bc
11 changed files with 286 additions and 24 deletions
@@ -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;
@@ -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',
@@ -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;
}
@@ -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;
+22 -5
View File
@@ -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<EventSource, string> = {
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,
+27 -2
View File
@@ -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<EventKind, number> = {
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];
@@ -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,
]),
],
@@ -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<void> {
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<void> {
const pending = await this.events.find({
where: {
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
@@ -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: [