generated from ttipo/project-base
feat(backend): 장소·축제·공연·전시 데이터 기반(장소 DB·동기화·조회 API) #1
@@ -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<void> {
|
||||
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<void> {
|
||||
// 되돌리기 전에 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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user