feat(backend): 장소·축제·공연·전시 데이터 기반(장소 DB·동기화·조회 API) #1

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