From 009a287533f50f04abe73b43f1acaedf4a8438fe Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 19:44:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=B6=95=EC=A0=9C=C2=B7?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=8C=80?= =?UTF-8?q?=EA=B5=AC=20=EA=B7=BC=EA=B5=90(=EA=B2=BD=EB=B6=81)=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=84=93=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관광공사에 등록된 앞으로 90일 행사가 대구 1건·경북 7건뿐이라(조회 조건 문제 아님) 홈 '이번 주말 축제·행사'가 대부분 비게 된다. 근교 행사를 거리와 함께 보여 준다. - place:sync -- event 가 기본으로 대구(27)·경북(47)을 받는다(--regions= 로 변경, 시·도마다 실행 기록을 따로 남긴다) - GET /api/events 반경 상한 50km → 150km(포항 약 85km·청송 약 100km). 기본 5km 는 그대로 - 설계 문서에 화면 규칙: 홈은 동네 좌표 120km 거리순(대구 먼저), 이번 주말이 비면 같은 반경 30일로 '다가오는 축제·행사'. 장소 상세 '근처'는 5km, 없으면 칸 숨김 2026-11-03 실측: 이번 주말 대구 도심 120km 안 5건(수성국제비엔날레 5km 가 맨 앞). Co-Authored-By: Claude Opus 5.5 --- backend/src/commands/place-sync.ts | 27 ++++++++++++++++--- .../place/dto/event-search-query.dto.ts | 3 ++- .../src/modules/place/sync/festival.sync.ts | 18 +++++++++++++ docs/design/place-db.md | 17 +++++++++--- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts index 599810d..3f4233e 100644 --- a/backend/src/commands/place-sync.ts +++ b/backend/src/commands/place-sync.ts @@ -5,6 +5,7 @@ import { WinstonModule } from 'nest-winston'; import { DEFAULT_EVENT_LOOKAHEAD_DAYS, + DEFAULT_EVENT_REGIONS, FestivalSync, } from '@/modules/place/sync/festival.sync'; import { PlaceDedupe } from '@/modules/place/sync/place-dedupe'; @@ -37,7 +38,7 @@ const USAGE = `사용법: place:sync <작업> [옵션] store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다 --signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구) event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다 - --region=27 법정동 시·도 코드 (기본 27 대구) + --regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북) --days=90 며칠 뒤까지 받을지 dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행) reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번) @@ -61,6 +62,7 @@ function parseArgs(argv: string[]): { skipDetails: boolean; signgu: string[]; days: number; + regions: string[]; } { const [first, ...rest] = argv; const job = (JOBS as readonly string[]).includes(first ?? '') @@ -80,23 +82,40 @@ function parseArgs(argv: string[]): { const days = daysArg ? Number(daysArg.slice('--days='.length)) : DEFAULT_EVENT_LOOKAHEAD_DAYS; + const regionsArg = rest.find((a) => a.startsWith('--regions=')); + const regions = regionsArg + ? regionsArg + .slice('--regions='.length) + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + : DEFAULT_EVENT_REGIONS; return { job, region, skipDetails: rest.includes('--skip-details'), signgu, days, + regions, }; } async function main(): Promise { - const { job, region, skipDetails, signgu, days } = parseArgs( + const { job, region, skipDetails, signgu, days, regions } = parseArgs( process.argv.slice(2), ); const validSigngu = signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v)); const validDays = Number.isInteger(days) && days >= 1 && days <= 365; - if (!job || !/^\d{2}$/.test(region) || !validSigngu || !validDays) { + const validRegions = + regions.length > 0 && regions.every((v) => /^\d{2}$/.test(v)); + if ( + !job || + !/^\d{2}$/.test(region) || + !validSigngu || + !validDays || + !validRegions + ) { Logger.error(USAGE, 'PlaceSync'); process.exitCode = 1; return; @@ -113,7 +132,7 @@ async function main(): Promise { } else if (job === 'store') { await app.get(StoreSync).run({ signguCodes: signgu }); } else if (job === 'event') { - await app.get(FestivalSync).run({ regionCode: region, days }); + await app.get(FestivalSync).runRegions({ regionCodes: regions, days }); } else if (job === 'dedupe') { await app.get(PlaceDedupe).run(); } else if (job === 'reparse-hours') { diff --git a/backend/src/modules/place/dto/event-search-query.dto.ts b/backend/src/modules/place/dto/event-search-query.dto.ts index 8bb28b9..2eb210b 100644 --- a/backend/src/modules/place/dto/event-search-query.dto.ts +++ b/backend/src/modules/place/dto/event-search-query.dto.ts @@ -25,7 +25,8 @@ 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; +/** 대구 근교(경북) 행사까지 거리순으로 보려면 100km 이상이 필요하다(포항 약 85km · 청송 약 100km) */ +export const EVENT_RADIUS_MAX_M = 150_000; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; diff --git a/backend/src/modules/place/sync/festival.sync.ts b/backend/src/modules/place/sync/festival.sync.ts index 9d5e51a..6b2af32 100644 --- a/backend/src/modules/place/sync/festival.sync.ts +++ b/backend/src/modules/place/sync/festival.sync.ts @@ -16,6 +16,12 @@ import { isSuspiciousRemoval } from './removal-guard'; import { SyncRunRecorder } from './sync-run.recorder'; import type { TourApi, TourItem } from './tour-api.client'; +/** + * 행사를 받을 시·도 — 대구(27)만으로는 90일에 1건뿐이라(2026-11-03 실측: 대구 1 · 경북 7) 근교 경북(47)까지 받는다. + * 화면은 동네 좌표에서 거리순으로 보여 주므로 대구 행사가 먼저 나온다. + */ +export const DEFAULT_EVENT_REGIONS = ['27', '47']; + /** 오늘부터 며칠 안에 열리는 행사를 받을지 */ export const DEFAULT_EVENT_LOOKAHEAD_DAYS = 90; /** 관광공사 축제·공연·행사 관광타입 */ @@ -85,6 +91,18 @@ export class FestivalSync { private readonly apiFactory: PublicDataApiFactory, ) {} + /** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */ + async runRegions(options: { + regionCodes: string[]; + days?: number; + }): Promise { + const results: FestivalSyncResult[] = []; + for (const regionCode of options.regionCodes) { + results.push(await this.run({ regionCode, days: options.days })); + } + return results; + } + async run(options: { regionCode: string; days?: number; diff --git a/docs/design/place-db.md b/docs/design/place-db.md index ce05e4e..a249dc7 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -248,7 +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 -- 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 # 적재 현황·오늘 쓴 호출 수 @@ -352,7 +352,11 @@ node dist/commands/place-sync.js tour-kor ## 12. 축제·공연·행사 (구현됨) -**동기화** `place:sync -- event [--region=27] [--days=90]` (매일) +**동기화** `place:sync -- event [--regions=27,47] [--days=90]` (매일) + +- **범위는 대구(27) + 경북(47)**: 2026-11-03 진단(`tools/data-check/festival_region_check.py`) 결과 앞으로 90일 관광공사 행사가 + 전국 106 · 대구 1 · 경북 7건이었다. 조회 조건 문제는 없었다(지역 코드 빈 행사 0건). 관광공사는 행사가 가까워져야 + 등록하는 경우가 많고 겨울은 행사 자체가 적다. 근교 행사를 거리와 함께 보여 주려고 경북까지 받는다. - `searchFestival2` 를 오늘~N일 기간으로 부른다. 서버는 기간이 겹치는 행사를 준다(종료일 ≥ 요청 시작일). - 지역 조건(`lDongRegnCd`)을 보내고, 응답에서도 다른 지역 행사는 한 번 더 거른다(서버가 조건을 무시해도 섞이지 않게). @@ -366,8 +370,13 @@ node dist/commands/place-sync.js tour-kor | 엔드포인트 | 내용 | | --- | --- | -| `GET /api/events` | `from`·`to`(YYYY-MM-DD, 기본 오늘~30일, 최대 92일) · `lat`+`lng`+`radius`(기본 5km, 최대 50km) · `kind` · `cursor` · `limit`. 기간과 겹치는 행사를, 좌표가 있으면 거리순·없으면 시작일순으로. **60일 넘는 상시 프로그램과 숨김 행사는 뺀다.** 항목에 `ongoing`(오늘 진행 중) | +| `GET /api/events` | `from`·`to`(YYYY-MM-DD, 기본 오늘~30일, 최대 92일) · `lat`+`lng`+`radius`(기본 5km, 최대 150km — 근교 경북까지) · `kind` · `cursor` · `limit`. 기간과 겹치는 행사를, 좌표가 있으면 거리순·없으면 시작일순으로. **60일 넘는 상시 프로그램과 숨김 행사는 뺀다.** 항목에 `ongoing`(오늘 진행 중) | | `GET /api/events/{id}` | 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001 | -- 화면 쓰임: 홈 '이번 주말 축제·행사' = 토·일 날짜로 `from`·`to`, 장소 상세 '근처 축제·행사' = 장소 좌표 + 반경. +- **화면 규칙**(2026-11-03 합의): + - 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=120000 → 대구 행사가 먼저, 다음 경북(거리 표시 "대구에서 85km"). + - **비면** 같은 반경으로 `from`=오늘 · `to`=오늘+30일을 불러 제목을 '다가오는 축제·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다). + - 장소 상세 '근처 축제·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다. +- 다음 보강(조사 예정): 대구는 공연·전시가 많은데 관광공사에는 거의 없다 → 공연예술통합전산망(KOPIS)·문화체육관광부 문화정보 API 의 + 이용 조건(저장 허용·출처 표시·호출 한도)을 확인한 뒤 출처로 추가한다. 운영자 직접 등록은 운영자 화면이 생기면. - 계약 테스트: `backend/test/festival.e2e-spec.ts`(12개) — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.