generated from ttipo/project-base
feat(backend): 축제·공연·행사 동기화와 조회 API 추가
홈 '이번 주말 축제·행사'와 장소 상세 '근처 축제·행사'에 쓸 데이터를 채운다.
- place:sync -- event: 관광공사 searchFestival2 를 오늘~90일 기간으로 받아
festival_event 에 upsert(서버는 기간이 겹치는 행사를 준다). 지역 조건을 보내고
응답에서도 다른 지역 행사는 거른다. 상세(소개·요금·공연 시간·장소)는 새로 생기거나
수정된 행사만 받는다. 기간 안에서 목록에 없는 행사는 숨긴다
- GET /api/events: 기간(기본 오늘~30일, 최대 92일)·근처(좌표+반경)·분류·커서.
60일 넘는 상시 프로그램과 숨김 행사는 뺀다. 항목에 오늘 진행 중 여부
- GET /api/events/{id}: 소개·공연 시간·요금·장소·홈페이지·출처
- 마이그레이션: festival_event 에 분류 코드·지역 코드·장소명·전화·홈페이지·
작은 사진·원본 수정일·상세 수신 시각 추가
e2e(test/festival.e2e-spec.ts)는 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
2026-11-03 실측: 앞으로 90일 관광공사 행사는 전국 106건, 대구 1건, 경북 7건.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user