30 Commits

Author SHA1 Message Date
ttipo 170be20b6d test(backend): e2e 스위트를 순서대로 실행
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m48s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m39s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m33s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m27s
CI / Frontend (lint · type-check · test) (push) Successful in 11m0s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m46s
빈 DB 에서 여러 e2e 스위트가 동시에 마이그레이션하면 같은 테이블을 만들다
pg_class_relname_nsp_index 중복으로 무작위 실패했다(스위트 5개에서 재현, CI 도 빈 DB 라 같은 위험).
스위트들은 한 DB 를 함께 쓰므로 maxWorkers 1 로 순서대로 돌린다. 빈 DB 두 번 연속 통과(약 30초).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:27:59 +09:00
ttipo e383c2e242 feat(backend): 같은 출처에 두 번 등록된 전시를 하나로 합침
한국문화정보원에 같은 전시가 제목만 다르게 두 번 등록되어 목록에 2건으로 나왔다
("문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》", 같은 갤러리·같은 기간).

같은 장소 · 같은 시작·종료일 · 제목 비교 키가 연속 6글자 이상 겹치면 같은 전시로 보고
먼저 등록된 것만 남긴다. 장소·기간만으로 합치지 않는 이유는 여러 전시실에서 같은 날
여러 전시를 여는 곳이 있어서다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:27:59 +09:00
ttipo 7e59ac0205 docs: 한국문화정보원 전시 동기화와 KOPIS 제목 정리 기록
place-db.md §12.1 에 지역 꼬리표 제거 규칙, §12.2 에 전시 동기화 순서·중복 판단·이미지 보류를 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:02:16 +09:00
ttipo 175e2d454b feat(backend): 한국문화정보원 전시 동기화 추가
전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를
place:sync -- kcisa 로 매일 받는다.

- 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다)
- 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts)
- 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도)
- 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다
- htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 &amp;lsquo; 처럼 이중 인코딩되어 온다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:02:15 +09:00
ttipo 94c8693dad test(backend): 에러 코드 계약 테스트의 타입 오류 수정
ERROR_CODES 는 코드마다 리터럴 타입이 달라 선택 속성 clientOnly 를 바로 읽으면 tsc 가 실패했다
(jest 는 타입 검사를 하지 않아 통과하고 있었다). 공통 정의 타입으로 넓혀 읽는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:31:53 +09:00
ttipo 4b6eecd679 docs(rules): 유예 취약점과 잠금 파일 npm 버전 주의 기록
- security.md: 남은 36건(glob·minimatch 보통, deepmerge·jest 낮음)의 도달 가능성·유예 이유·다시 볼 조건,
  multer ^2.5.0 · swagger 아래 js-yaml override 근거
- nestjs 스킬: 컨테이너 npm 11.20 이 지운 선택 peer 항목 때문에 CI(npm 10)의 npm ci 가 깨질 수 있다 —
  npx npm@10 ci --dry-run 으로 확인하는 절차

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:31:53 +09:00
ttipo 3e2adea95c chore(backend): 의존성 취약점 정리(치명 4·높음 3 → 0)
npm audit 47건(치명 4 · 높음 3) 중 범위 안에서 올릴 수 있는 것을 모두 올렸다.

- 컨테이너 안에서 npm audit fix: proxy-addr·piscina·handlebars·decompress·body-parser·qs 등
- Nest 11 최신판(11.2.8)으로 업데이트: 낡은 path-to-regexp 8.2.0(높음) 고정 해소
- overrides: multer ^2.5.0(2.4.0 에 DoS 취약점, platform-express 12.3.2 가 2.5.0 선언),
  @nestjs/swagger 아래 js-yaml ^5.4.3(5.3.0 고정, swagger 12.1.0 이 5.5.0 선언)

컨테이너 npm 11.20 이 선택 peer 의존성 항목(chokidar 4.0.3)을 잠금 파일에서 지워
CI 와 같은 npm 10 의 npm ci 가 실패해서, 그 항목만 이전 잠금 파일에서 되살렸다.
npm 10 · 11.6 · 11.20 모두 npm ci 통과, lint · 단위 185 · e2e 39 · 빌드 · 서버 기동(/api-docs-yaml 포함) 확인.
남은 36건(보통 33 · 낮음 3)은 메이저 업그레이드로만 풀려 security.md 에 유예로 기록한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:31:53 +09:00
ttipo 307204ef09 fix(backend): KOPIS 공연 제목의 지역 꼬리표 제거
순회 공연은 제목 끝에 "[대구]"·"[대구 북구]"·"[안동] (11월)" 같은 지역 표시가 붙어 온다
(대구·경북 244건 중 203건). 앱 목록에서는 군더더기라 저장할 때 뗀다.
꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, "(앵콜)" 같은 메모는 남긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:13:03 +09:00
ttipo e7c2506ce7 chore: 공연·전시 API 진단 스크립트 추가
KOPIS(https·조회 기간·건수·시설 좌표·afterdate)와 한눈에보는문화정보(건수·분야·좌표 채움)를
구현 전에 실제로 불러 확인한다. 키는 backend env 에서 읽고 출력하지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:07:44 +09:00
ttipo ec4a2a3db8 docs: 공연·전시 출처 조사와 KOPIS 동기화 설계 정리
- 조사 문서: KOPIS·한국문화정보원·대구문화예술진흥원 이용 조건·필드·한도, 사용자 확인(KOPIS 저장·포스터 재사용 가능), 2026-11-03 진단 결과
- place-db.md v1.7: 여러 출처 스키마(§2.4), KOPIS 동기화 순서·좌표 검사·호출량·알려진 한계(§12.1), 다음 출처(§12.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:07:44 +09:00
ttipo 45db80d683 feat(backend): KOPIS 공연 동기화 추가
대구 공연은 관광공사에 거의 없고(90일 1건) KOPIS 에는 153건이 있다.
place:sync -- kopis 로 대구·경북 공연을 매일 받는다.

- 목록 → 새·바뀐(afterdate)·7일 지난 공연만 상세 → 새 공연장만 시설 상세 → 시설 좌표를 공연에 복사
- 시설 좌표가 주소의 시·도 영역 밖이면 비운다(대구 중구 주소에 경주 좌표인 시설이 있었다)
- 포스터 http 주소를 https://kopis.or.kr 로 바꿔 저장, 0.6초 간격 호출(초당 10회 차단)
- 공공데이터 클라이언트에 키 파라미터 이름·호출 간격·경로를 나눠 받게 함
- XML 응답 해석에 fast-xml-parser 추가. 컨테이너 npm 11.20 이 만든 잠금 파일은
  선택 peer·@emnapi 항목을 지워 CI(npm 10)·호스트의 npm ci 가 실패해서, 새 패키지 항목만 기존 잠금에 더했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:02:31 +09:00
ttipo d3d66444bc feat(backend): 행사 출처 구분·전시 종류·공연장 테이블 추가
KOPIS 공연·한국문화정보원 전시를 관광공사 행사와 같은 테이블에 받으려면
관광공사 contentid 단독 유니크로는 출처가 섞인다. (source, source_id) 로 바꾸고,
KOPIS 공연은 좌표가 공연장 상세에만 있어 event_venue 에 따로 둔다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:01:23 +09:00
ttipo 009a287533 feat(backend): 축제·행사 범위를 대구 근교(경북)까지 넓힘
관광공사에 등록된 앞으로 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 <noreply@anthropic.com>
2026-11-03 19:44:28 +09:00
ttipo 16757b31f2 chore: 축제 지역 조회 진단 스크립트 추가
대구 축제·행사가 90일 동안 1건만 잡힌 이유가 데이터 부족인지 조회 조건 때문인지
가르려고 만들었다(지역+종료일 / 지역만 / 전국 비교, searchFestival2 5~6회).
2026-11-03 결과: 조건 문제는 없고(코드 빈 행사 0건) 등록된 대구 행사가 실제로 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:30:09 +09:00
ttipo 4371f24c46 chore(docker): 개발 백엔드 메모리 한도를 2GB 로 올림
dev 는 파일 감시(폴링)와 타입 검사(nest start --watch)가 함께 돌아 운영보다 무겁다.
base 의 1GB 로는 여러 파일을 연달아 저장할 때 두 번(2026-11-02·03) 메모리 부족으로
종료됐다(OOMKilled). 운영 한도는 그대로 두고 dev 오버라이드에서만 올린다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:30:08 +09:00
ttipo 295d74f4d3 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>
2026-11-03 19:30:08 +09:00
ttipo 55f10c5c7a fix(backend): 숨김·폐업 처리 안전장치를 항목 수에 맞게 조정
"이번 목록이 기존의 80% 미만이면 원본 장애로 보고 숨기지 않는다"는 규칙은
항목이 적은 곳(축제 몇 건, 동네 빙수집 5곳)에서 정상적인 한 건 취소도 막았다.
사라진 수를 직접 세어, 5건을 넘고 기존의 20% 도 넘을 때 또는 기존이 전부
사라졌을 때(빈 응답)만 원본 장애로 본다. 관광공사·상가정보 동기화가 함께 쓴다.

상가정보는 업종이 바뀐 업소(카페 → 빵집)를 사라진 것으로 세지 않도록
묶음별 개수 대신 이번에 본 업소 id 전체와 비교한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:30:07 +09:00
ttipo 3d2cd99ef0 fix(backend): 영업시간 파서가 붙은 구간·요일 머리말을 잘못 읽던 문제 수정
대구 관광공사 원문을 실제로 적재해 보니 형식이 제각각이라 시간표를 잘못 읽는 곳이 있었다.
예: 더현대 대구 "월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30" 을
월~목에 두 시간대, 금~일은 비어 있음으로 읽어 월요일 20:15 에 "영업 중"으로 나갔다.

- 줄바꿈을 구간 구분으로 본다(동기화가 <br> 을 줄바꿈으로 바꿔 저장하는데 공백으로 합쳐지고 있었다)
- 시각 바로 뒤에 요일·평일·주말·'[' 가 붙으면 구간을 나눈다(하이픈 시간 범위는 그대로)
- 괄호·대괄호 안의 '/' 로 구간을 자르지 않고, 괄호 안 브레이크타임·라스트오더를 각각 읽는다
- "[토요일]" 같은 대괄호 요일 머리말을 다음 줄들의 시간에 적용하고, "[1월]" 의 '월'은
  월요일로 읽지 않는다(달마다 다르면 계절별처럼 시각 판단을 하지 않는다)
- 주중·평일 줄의 브레이크타임은 평일 전용으로 본다
- htmlToText 가 잘못 쓴 </br> 도 줄바꿈으로 바꾼다

저장된 원문을 API 재호출 없이 다시 해석하는 `place:sync -- reparse-hours` 를 추가했다
(jsonb 키 순서와 무관하게 비교). 2026-11-03 대구 1,174곳 중 124곳의 시간표를 바로잡았다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:59:20 +09:00
ttipo df48ed78eb feat(backend): 장소 조회 API와 같은 가게 중복 병합 추가
검색·지도·상세 화면이 장소 DB 를 쓰도록 조회 API 를 연다(비로그인 허용).

- GET /api/places: 이름 검색, 좌표+반경 거리순, 분류·반려동물·실내 필터,
  키셋 커서 페이지네이션(정렬값+id), 전체 개수, 항목별 "지금 영업 중"(KST)
- GET /api/places/{id}: 영업시간 원문·해석 상태·출처, 반려동물 크기·조건,
  이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기.
  병합된 id 로 들어오면 병합 대상 장소를, 숨김·폐업 추정이면 RES_001 을 준다
- 같은 가게 병합: 관광공사 쪽이 카페·식당·주점이고 100m 안이며 상호가 같거나
  포함(짧은 쪽 2글자 이상)이면 상가정보 쪽을 merged_into_id 로 관광공사 쪽에 합친다.
  tour-kor·store 가 끝날 때 자동으로, place:sync -- dedupe 로 따로도 돈다
- 사후면세점 브랜드 매장(SH040300)은 목록·필터에서 빼고 이름 검색에만 낸다
  (반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제)
- 공공 반려동물 조건 원문의 글머리표·줄바꿈 정리

e2e(test/place.e2e-spec.ts)는 시작할 때 마이그레이션을 적용하고 동해 좌표에
테스트 장소를 만들어 노출 규칙·필터·커서·상세·오류 코드를 검증한다.
2026-11-02 대구 실측: 같은 가게 21곳 병합, 도심 반려동물 필터 7곳.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:57:58 +09:00
ttipo 71aaf0bcae feat(backend): 상가정보 데이트 업종 적재 명령 추가
`npm run place:sync -- store [--signgu=...]` 로 소상공인 상가정보의 데이트 업종
6개(카페·빵/도넛·아이스크림/빙수·샌드위치/샐러드·요리 주점·생맥주)를 장소 DB 에
적재한다. 기본 범위는 1차 큐레이션 동네가 속한 대구 중구·남구·수성구다.

- 분기마다 다시 실행하면, 끝까지 받은 (시군구, 업종) 묶음에서 사라진 업소를
  gone(폐업 추정)으로 숨긴다. 지우지 않고, 결과가 기존의 80% 미만이면 건너뛴다
- 기준년월(stdrYm)을 source_snapshot 에 남긴다
- 공공데이터포털 공통 규칙(키 가림·간격·재시도·한도)을 PublicDataClient 로 묶어
  관광공사·상가정보 클라이언트가 함께 쓴다(TourApiFactory → PublicDataApiFactory)

2026-11-02 대구 실측: 기준 2026-09, 3,776곳(18회 호출). 세 동네 반경 안 업소 수는
후보표(반경 API)와 거의 같다(도심 1,295/1,316 · 수성못 425/425 · 앞산 262/263).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:00:13 +09:00
ttipo 9775d6de45 feat(backend): 관광공사 장소·반려동물 동기화 명령 추가
`npm run place:sync -- tour-kor|tour-pet|status` 로 관광공사 국문 장소와
반려동물 동반 정보를 장소 DB 에 적재한다. API 서버가 아니라 CLI(운영은 cron)로 돈다.

- tour-kor: 시·도 지역 목록 전체 → place upsert → 상세(개요·영업시간·요금·주차).
  개발 계정 한도(오퍼레이션별 하루 1,000건)에 닿으면 멈추고 detail_synced_at 으로
  다음 실행이 이어 받는다. 서버의 한도 초과 응답(코드 22)도 정상 멈춤으로 본다.
  수정일이 바뀐 장소만 상세를 다시 받고, 목록에서 사라진 장소는 숨긴다
- tour-pet: detailPetTour2 전체(약 10회)를 받아 전구역/일부구역으로 요약
- 분류 대응을 「신분류체계 연계 정의서」 소분류 240개로 확정하고 'other' 분류 추가
- 키는 어떤 오류 메시지에도 남기지 않는다(원문·URL 인코딩 모두 가림)

CHECK 제약 변경은 migration:generate 가 감지하지 못해 마이그레이션에 직접 적었다.
2026-11-02 대구 실측: 목록 1,193건 → 장소 1,174곳, 상세 535곳(영업시간 해석 92%),
반려동물 정보 288곳(그중 쇼핑 매장 275곳).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:46:43 +09:00
ttipo cd089f76fb chore(docker): 백엔드·프론트엔드 .dockerignore 추가
없으면 Dockerfile 의 `COPY . .` 가 폴더를 통째로 이미지에 넣는다.
Windows 호스트에서 백엔드 빌드 컨텍스트가 263MB(호스트 node_modules 포함)로
4분 넘게 걸렸고, 두 가지 문제가 있었다.

- 호스트(Windows) node_modules 가 컨테이너의 Linux 모듈을 덮어써 swc 등
  네이티브 모듈이 깨질 수 있다
- backend/.env.{환경} 의 키가 이미지 레이어에 남는다(env 는 compose 가
  실행할 때 넣으므로 이미지에 필요 없다)

node_modules·dist·coverage·.env*·uploads 등을 제외해 컨텍스트가 약 1MB 가 됐다.
프론트엔드는 VITE_API_BASE_URL 을 build arg 로 받으므로 결과가 같다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:46:41 +09:00
ttipo 13bf3c6e40 chore: 네이버 지도 Flutter 1일 시험 앱 추가
flutter_naver_map 은 권장안이지만 유지보수 1인·최근 릴리스 공백이 있어
채택 전에 하루 시험한다. 코스 지도(마커·경로선), 핀 1,000개 묶음,
지명 언어, 한 화면에 지도 두 개, 지도 화면 열고 닫기 20번의 5개 화면으로
우리 요구와 안정성을 확인한다. NCP Client ID 는 코드에 두지 않고
--dart-define=NAVER_MAP_CLIENT_ID 로만 받는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:53:52 +09:00
ttipo 5535ba19ed docs: AI 코스 추천 평가셋 초안 추가
AI 코스 생성 품질을 프롬프트·모델을 바꿀 때마다 같은 기준으로 재기 위한
62개 사례(데이트·축제·반려동물·카카오 데이터 경계·날씨·예외 입력)와
사례별 확인 항목·기대 동작이다. 대구 사례는 아직 넣지 않았다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:53:25 +09:00
ttipo 166f5a04b9 docs: 공공데이터 API 원본 활용 매뉴얼 보관
관광공사(국문·영문·반려동물·신청 방법·분류코드), 기상청 단기예보,
소상공인 상가정보의 공공데이터포털 배포 매뉴얼 원본이다.
docs/research/data-openapi-review/ 의 검토 노트가 이 파일들을 근거로 인용한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:53:25 +09:00
ttipo 0b385a896e docs: 착수 전 조사와 공공데이터 점검 결과 정리
개발 전에 확정해야 할 전제(데이터 저장 가능 범위·지도 SDK·계정 리드타임)와
공공데이터 실측 결과를 남긴다.

- pre-dev-check: 카카오 장소 데이터 저장·LLM 전송 금지 조건, 지도 SDK 비교
  (네이버 권장), 스토어·위치기반서비스 신고 등 계정·승인 체크리스트
- data-openapi-review: 관광공사 국문·영문·반려동물, 상가정보, 단기예보 매뉴얼 검토
- data-check: 서울·부산·대구 점검 요약과 원자료(JSON, 키 미포함)

10-29 점검의 관광공사·반려동물 개수는 위치 검색으로 센 과소 집계였다.
2026-11-02 재측정 결과를 각 요약 맨 위에 정정 메모로 달았다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:53:24 +09:00
ttipo 82087b7ac7 chore: 공공데이터 점검·큐레이션 후보 도구 추가
대구 1차 출시 지역의 공공데이터를 점검하고 큐레이션 작업표를 만드는
스크립트다. 키는 backend/.env.development 에서 읽고 출력·오류 문구에서 가린다.

- public_data_check(2).py · run_region.py: 관광공사·반려동물·축제·상가정보·
  단기예보 점검 (결과는 docs/research/data-check/)
- curation_candidates.py: 동네별 큐레이션 후보표(CSV). 관광공사는 위치 검색이
  반경 안 장소의 15–38%만 돌려줘서 시·도 지역 목록을 받아 반경을 직접 거른다
- curation_shortlist.py: 후보표에 우선순위(1~4·제외)를 매기고 시범표를 만든다
  (키 불필요)

결과 CSV 는 data/ 아래에 생기며 .gitignore 대상이다. 파이썬 캐시도 무시한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:53:09 +09:00
ttipo 2d0d760d98 feat(backend): 장소 DB 스키마와 마이그레이션 추가
공공데이터 장소(place)와 운영자 큐레이션(place_curation)을 테이블로 나눠
동기화가 사람이 확인한 값을 덮어쓰지 않게 한다. 앱이 보는 값(큐레이션 >
공공 > 미확인)과 노출 규칙은 place_listing 뷰 한 곳에서만 정한다.

- 노출 규칙(2026-11-02 합의): 노출 중인 장소는 모두 검색, 홈 추천은
  추천(O)+확인일, AI 후보는 1순위 큐레이션 추천·2순위 관광공사 장소.
  확인 안 된 상가정보 매장은 AI 후보가 아니다
- 축제·공연·행사(festival_event), 언어별 텍스트(place_i18n),
  동기화 기록(place_sync_run) 포함
- 코드값은 place.constants.ts 하나로 CHECK 제약·뷰가 공유
- 전역 SnakeNamingStrategy(DB snake_case ↔ TS camelCase), uuid 는 pgcrypto

임시 Postgres 15 에서 생성 → 적용 → 재생성 무차이 → revert 를 확인했고,
뷰 규칙은 8가지 경우로 검증했다. 설계: docs/design/place-db.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:51:03 +09:00
ttipo 52de9917e4 feat(backend): 공공데이터 영업시간 문장 파서 추가
관광공사 영업시간·쉬는날은 "11:00~21:00 (준비 시간 15:30~17:00)",
"화~일 10:00~18:00 (월요일 휴관)", "점포별 상이"처럼 자유 문장이라
코스 영업시간 경고와 AI 시간 검증에 바로 쓸 수 없다.
요일별 시간표·준비시간·마지막 주문·정기 휴무·명절 휴무로 해석하고,
정할 수 없는 경우(점포별 상이·계절별·값 없음)는 지어내지 않고 상태로 남긴다.
테스트는 2026-10-29 공공데이터 점검에서 받은 실제 문장으로 작성했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:46:55 +09:00
ttipo da4db6123b chore(backend): 공공데이터·카카오·Claude API 키 환경변수 예시 추가
장소 데이터 적재(공공데이터포털)·장소 검색(카카오)·AI 코스 생성(Claude)에
필요한 키 자리를 placeholder 로 추가한다. 카카오 응답의 저장·LLM 전송 금지
조건을 주석으로 남겨 키를 쓰는 사람이 바로 알 수 있게 한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:45:28 +09:00
208 changed files with 21483 additions and 1089 deletions
+13 -3
View File
@@ -85,7 +85,12 @@
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
> **현재 유예 중인 항목** (2026-11-03 backend `npm audit` 36건: 보통 33 · 낮음 3, 높음·치명 0)
>
> | 항목 | 경로 | 도달 가능성 | 미루는 이유 | 다시 볼 조건 |
> | --- | --- | --- | --- | --- |
> | `glob`·`minimatch` (보통) | typeorm(엔티티·마이그레이션 경로 탐색), jest·eslint·@nestjs/cli | 패턴이 코드에 고정된 값이라 외부 입력이 닿지 않는다. 나머지는 개발 도구 | 수정판이 typeorm 1.x 등 메이저 업그레이드뿐 | typeorm 1.x 전환 · jest/eslint 메이저 업그레이드 때 |
> | `deepmerge` · `jest` (낮음) | @nestjs/cli, jest | 개발 도구 전용(운영 이미지에 없음) | 수정판이 메이저 업그레이드뿐 | @nestjs/cli 12 · jest 다음 메이저 때 |
### 전이 의존성 취약점은 overrides 로 끊는다
@@ -95,18 +100,23 @@
```jsonc
// backend/package.json
"overrides": {
"multer": "^2.4.0"
"multer": "^2.5.0",
"@nestjs/swagger": { "js-yaml": "^5.4.3" } // 특정 패키지 아래에서만 올릴 때
}
```
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
>
> 2026-11-03 갱신: `multer@2.4.0` 에도 DoS 취약점(high)이 나와 `^2.5.0` 으로 올렸다(platform-express 12.3.2 가 2.5.0 을 선언).
> `@nestjs/swagger@11.4.7` 은 `js-yaml@5.3.0`(보통)을 고정한다. swagger 12.1.0 이 5.5.0 을 선언하므로 swagger 아래에서만 올렸다
> — 최상위로 올리면 `js-yaml@4` 를 쓰는 eslint 까지 메이저가 바뀐다.
규칙:
- override 는 **상위 패키지가 그 버전을 실제로 쓰는지 근거를 확인**하고 적용한다
(위 사례는 platform-express v12 가 2.4.0 을 선언하는 것이 근거).
(위 사례는 platform-express v12 가 2.5.0 을, swagger 12.1.0 이 js-yaml 5.5.0 을 선언하는 것이 근거).
- 같은 메이저 안에서만 올린다. 메이저를 건너뛰면 API 가 달라져 상위 패키지가 깨질 수 있다.
- 적용 후 **lint·테스트·빌드·런타임 기동까지 확인**한다. override 는 npm 이 검증해 주지 않는다.
- 상위 패키지를 정식으로 올릴 때 **override 를 제거**한다. 남겨두면 나중에 버전이 거꾸로 고정된다.
+4
View File
@@ -59,6 +59,10 @@ describe('formatSomething', () => {
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
> ```
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
>
> e2e 스위트는 **한 DB 를 함께 쓰므로 순서대로 돈다**(`jest-e2e.json` 의 `maxWorkers: 1`).
> 빈 DB 에서 여러 스위트가 동시에 마이그레이션하면 같은 테이블을 만들다 부딪혀 무작위로 실패한다(2026-11-03, 스위트 5개에서 재현).
> 새 e2e 스위트는 실제 데이터와 겹치지 않는 값(없는 지역 코드 `'99'`, `e2e-` 접두어 id, 2030년 날짜 등)만 쓰고 끝나면 지운다.
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
+5 -1
View File
@@ -298,7 +298,11 @@ dc exec backend npm uninstall <패키지>
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
현재 `multer: ^2.5.0` 과 `@nestjs/swagger` 아래 `js-yaml: ^5.4.3` 이 걸려 있다 — 각각 `@nestjs/platform-express`·`@nestjs/swagger` 를 v12 로 올릴 때 제거한다.
- **컨테이너 npm(11.20) 이 만든 잠금 파일이 CI 의 npm 10 에서 깨질 수 있다.** 새 npm 은 선택 peer 의존성(chokidar 등)·
다른 플랫폼용 선택 의존성 항목을 잠금 파일에서 지우는데, npm 10 의 `npm ci` 는 그 항목이 없으면 실패한다(2026-11-03).
의존성을 바꾼 뒤 호스트에서 `npx -y npm@10 ci --dry-run` 으로 확인하고, 실패하면 지워진 항목을 이전 잠금 파일에서 되살린다
(또는 컨테이너에서 `npx -y npm@11.6.2 install --package-lock-only` 로 다시 계산).
---
+4
View File
@@ -57,3 +57,7 @@ Thumbs.db
.cache/
.eslintcache
.npm/
# Python 캐시 (tools/ 점검 스크립트)
__pycache__/
*.pyc
+24
View File
@@ -0,0 +1,24 @@
# 이미지 빌드 컨텍스트에서 뺄 파일 — 빌드를 빠르게 하고, 비밀값·호스트 산출물이 이미지에 들어가지 않게 한다.
# (없으면 Dockerfile 의 `COPY . .` 가 아래 파일을 전부 이미지에 넣는다)
# 의존성 — 이미지 안에서 npm ci 로 설치한다.
# 호스트(Windows) node_modules 가 들어오면 Linux 용 모듈을 덮어써 네이티브 모듈(swc 등)이 깨진다.
node_modules
# 빌드·테스트 산출물 — 이미지 안에서 npm run build 로 다시 만든다
dist
coverage
*.tsbuildinfo
# 환경변수 — 실행할 때 compose 가 env_file/environment 로 넣는다. 이미지에 넣으면 키가 레이어에 남는다
.env
.env.*
# 업로드 파일(로컬 디스크 저장소) — 사용자 파일은 이미지가 아니라 볼륨에 둔다
uploads
# 기타
*.log
.git
.vscode
.idea
+19
View File
@@ -13,3 +13,22 @@
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# 공공데이터포털 일반 인증키 — 마이페이지의 "Decoding" 키를 넣는다.
# 한 키로 활용신청이 승인된 모든 API(관광공사 국문·영문·반려동물, 기상청 단기예보, 상가정보)를 호출한다.
# 개발 계정은 관광공사 API 하루 1,000건 제한이 있다.
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
# 운영 계정 승인 후 승인된 한도로 올린다.
TOUR_API_DAILY_LIMIT=
# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키).
# kopis.or.kr → Open API 에서 신청하면 이메일로 온다.
KOPIS_SERVICE_KEY=change-me-kopis-service-key
# 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다.
# 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
# Claude API 키 — AI 코스 생성. 사용량만큼 과금된다.
ANTHROPIC_API_KEY=change-me-anthropic-api-key
+16
View File
@@ -13,3 +13,19 @@
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# 공공데이터포털 일반 인증키(Decoding) — 운영 전 관광공사 API 는 활용사례를 등록해 운영 계정으로 트래픽을 늘린다.
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
# 운영 계정 승인 후 승인된 한도로 올린다.
TOUR_API_DAILY_LIMIT=
# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키).
# kopis.or.kr → Open API 에서 신청하면 이메일로 온다.
KOPIS_SERVICE_KEY=change-me-kopis-service-key
# 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
# Claude API 키 — 운영용 키를 따로 발급하고 사용량 한도를 걸어 둔다.
ANTHROPIC_API_KEY=change-me-anthropic-api-key
+1315 -1081
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -25,7 +25,9 @@
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"migration:show": "npm run typeorm -- migration:show"
"migration:show": "npm run typeorm -- migration:show",
"place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts",
"place:sync:prod": "node dist/commands/place-sync.js"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
@@ -41,6 +43,7 @@
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"fast-xml-parser": "^5.11.2",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"pg": "^8.20.0",
@@ -79,7 +82,10 @@
"typescript-eslint": "^8.20.0"
},
"overrides": {
"multer": "^2.4.0"
"multer": "^2.5.0",
"@nestjs/swagger": {
"js-yaml": "^5.4.3"
}
},
"jest": {
"moduleFileExtensions": [
+2
View File
@@ -8,6 +8,7 @@ import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { HealthModule } from '@/modules/health/health.module';
import { PlaceModule } from '@/modules/place/place.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -58,6 +59,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
}),
HealthModule,
PlaceModule,
UploadModule,
UserModule,
],
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaceSyncModule } from '@/modules/place/sync/place-sync.module';
import { buildDatabaseOptions } from '@/shared/config/database.config';
import { validateEnv } from '@/shared/config/env.validation';
/**
* 장소 동기화 CLI 전용 루트 모듈 — HTTP·Rate Limit·캐시 없이 설정·DB·동기화만 올린다.
* 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
*/
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
validate: validateEnv,
}),
TypeOrmModule.forRootAsync({
useFactory: () => ({
...buildDatabaseOptions(),
autoLoadEntities: true,
// 수천 건 upsert 의 SQL 로그는 진행 상황을 가린다
logging: false,
}),
}),
PlaceSyncModule,
],
})
export class PlaceSyncCliModule {}
+169
View File
@@ -0,0 +1,169 @@
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import {
DEFAULT_EVENT_LOOKAHEAD_DAYS,
DEFAULT_EVENT_REGIONS,
FestivalSync,
} from '@/modules/place/sync/festival.sync';
import { KcisaSync } from '@/modules/place/sync/kcisa.sync';
import { KopisSync } from '@/modules/place/sync/kopis.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';
import {
DEFAULT_STORE_SIGNGU,
StoreSync,
} from '@/modules/place/sync/store.sync';
import {
DEFAULT_TOUR_REGION,
TourKorSync,
} from '@/modules/place/sync/tour-kor.sync';
import { TourPetSync } from '@/modules/place/sync/tour-pet.sync';
import { winstonConfig } from '@/shared/logger/winston.config';
import { PlaceSyncCliModule } from './place-sync.cli.module';
/**
* 장소 동기화 명령 (설계: docs/design/place-db.md §4).
* 공공데이터·KOPIS 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다.
*
* 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션]
* 운영: node dist/commands/place-sync.js <작업> [옵션] (cron)
*/
const USAGE = `사용법: place:sync <작업> [옵션]
tour-kor 관광공사 국문 장소 적재·갱신 (목록 → 상세). 한도에 닿으면 멈추고, 다시 실행하면 이어서 받는다
--region=27 법정동 시·도 코드 (기본 27 대구)
--skip-details 목록만 갱신
tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출)
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
--regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
kopis KOPIS 공연 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 공연만 상세를, 새 공연장만 좌표를 받는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
kcisa 한국문화정보원 전시 적재(오늘부터 N일 안, 매일). 공연은 KOPIS 와 겹쳐 전시만, 이미지는 받지 않는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
status 지역별 적재 현황과 오늘 쓴 호출 수
--region=27`;
const JOBS = [
'tour-kor',
'tour-pet',
'store',
'event',
'kopis',
'kcisa',
'dedupe',
'reparse-hours',
'status',
] as const;
type Job = (typeof JOBS)[number];
function parseArgs(argv: string[]): {
job: Job | null;
region: string;
skipDetails: boolean;
signgu: string[];
days: number;
regions: string[];
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
? (first as Job)
: null;
const regionArg = rest.find((a) => a.startsWith('--region='));
const region = regionArg?.split('=')[1]?.trim() || DEFAULT_TOUR_REGION;
const signguArg = rest.find((a) => a.startsWith('--signgu='));
const signgu = signguArg
? signguArg
.slice('--signgu='.length)
.split(',')
.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;
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<void> {
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;
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;
}
const app = await NestFactory.createApplicationContext(PlaceSyncCliModule, {
logger: WinstonModule.createLogger(winstonConfig),
});
try {
if (job === 'tour-kor') {
await app.get(TourKorSync).run({ regionCode: region, skipDetails });
} else if (job === 'tour-pet') {
await app.get(TourPetSync).run();
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'event') {
await app.get(FestivalSync).runRegions({ regionCodes: regions, days });
} else if (job === 'kopis') {
await app.get(KopisSync).runRegions({ regionCodes: regions, days });
} else if (job === 'kcisa') {
await app.get(KcisaSync).runRegions({ regionCodes: regions, days });
} else if (job === 'dedupe') {
await app.get(PlaceDedupe).run();
} else if (job === 'reparse-hours') {
await app.get(PlaceHoursReparse).run();
} else {
await app.get(PlaceSyncStatus).report(region);
}
} finally {
await app.close();
}
}
main().catch((error: unknown) => {
// 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
Logger.error(
error instanceof Error ? error.message : String(error),
'PlaceSync',
);
process.exitCode = 1;
});
@@ -7,6 +7,7 @@ import {
isErrorCode,
resolveCodeByStatus,
} from './error-codes';
import type { ErrorCodeDefinition } from './error-codes';
import { BusinessException } from './business.exception';
describe('에러 코드 레지스트리', () => {
@@ -45,7 +46,8 @@ describe('에러 코드 레지스트리', () => {
});
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
const clientOnlyCodes = Object.entries(ERROR_CODES)
// 코드마다 리터럴 타입이 달라 공통 정의 타입으로 넓혀야 선택 속성(clientOnly)을 읽을 수 있다
const clientOnlyCodes = Object.entries<ErrorCodeDefinition>(ERROR_CODES)
.filter(([, definition]) => definition.clientOnly)
.map(([code]) => code);
@@ -0,0 +1,137 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlaceTables1793597729768 implements MigrationInterface {
name = 'AddPlaceTables1793597729768';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "place_curation" ("place_id" uuid NOT NULL, "verdict" text NOT NULL DEFAULT 'pending', "reason_ko" text, "reason_i18n" jsonb, "hours_text" text, "rest_text" text, "hours" jsonb, "pet_policy" text NOT NULL DEFAULT 'unknown', "pet_sizes" text array NOT NULL DEFAULT '{}', "pet_conditions" text, "setting" text, "official_url" text, "verified_method" text, "verified_at" date, "verified_by" text, "recheck_due_at" date, "memo" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "ck_place_curation_verified" CHECK ("verified_at" IS NULL OR "verified_method" IS NOT NULL), CONSTRAINT "ck_place_curation_verified_method" CHECK ("verified_method" IS NULL OR "verified_method" IN ('phone', 'official_channel', 'visit')), CONSTRAINT "ck_place_curation_setting" CHECK ("setting" IS NULL OR "setting" IN ('indoor', 'outdoor', 'both')), CONSTRAINT "ck_place_curation_pet_sizes" CHECK ("pet_sizes" <@ ARRAY['small', 'medium', 'large']::text[]), CONSTRAINT "ck_place_curation_pet_policy" CHECK ("pet_policy" IN ('allowed', 'partial', 'not_allowed', 'unknown')), CONSTRAINT "ck_place_curation_verdict" CHECK ("verdict" IN ('recommend', 'not_recommend', 'pending')), CONSTRAINT "PK_645f6298438801472c60ce4195c" PRIMARY KEY ("place_id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_curation_recheck_due_at" ON "place_curation" ("recheck_due_at") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_curation_verdict" ON "place_curation" ("verdict") `,
);
await queryRunner.query(
`CREATE TABLE "place" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "category" text NOT NULL, "source_category" text, "name" text NOT NULL, "branch_name" text, "address_road" text, "address_jibun" text, "lat" double precision NOT NULL, "lng" double precision NOT NULL, "ldong_regn_cd" text, "ldong_signgu_cd" text, "adong_cd" text, "phone" text, "homepage" text, "overview" text, "public_hours_text" text, "public_rest_text" text, "public_hours" jsonb, "public_pet" jsonb, "public_pet_policy" text, "public_extra" jsonb, "image_url" text, "image_thumb_url" text, "image_license" text, "status" text NOT NULL DEFAULT 'active', "source_modified_at" TIMESTAMP WITH TIME ZONE, "source_snapshot" text, "last_seen_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_place_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_place_lat_lng" CHECK ("lat" BETWEEN -90 AND 90 AND "lng" BETWEEN -180 AND 180), CONSTRAINT "ck_place_image_license" CHECK ("image_license" IS NULL OR "image_license" IN ('Type1', 'Type3')), CONSTRAINT "ck_place_public_pet_policy" CHECK ("public_pet_policy" IS NULL OR "public_pet_policy" IN ('allowed', 'partial')), CONSTRAINT "ck_place_status" CHECK ("status" IN ('active', 'hidden', 'gone')), CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging')), CONSTRAINT "ck_place_source" CHECK ("source" IN ('tour', 'store')), CONSTRAINT "PK_96ab91d43aa89c5de1b59ee7cca" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_status" ON "place" ("status") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_category" ON "place" ("category") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_lat_lng" ON "place" ("lat", "lng") `,
);
await queryRunner.query(
`CREATE TABLE "place_sync_run" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "job" text NOT NULL, "scope" text, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "finished_at" TIMESTAMP WITH TIME ZONE, "fetched" integer NOT NULL DEFAULT '0', "inserted" integer NOT NULL DEFAULT '0', "updated" integer NOT NULL DEFAULT '0', "hidden" integer NOT NULL DEFAULT '0', "gone" integer NOT NULL DEFAULT '0', "calls" jsonb NOT NULL DEFAULT '{}', "errors" jsonb NOT NULL DEFAULT '[]', "snapshot" text, CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure')), CONSTRAINT "PK_0262dd8226b47bf7ee739b633d6" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_sync_run_job_started_at" ON "place_sync_run" ("job", "started_at") `,
);
await queryRunner.query(
`CREATE TABLE "place_i18n" ("place_id" uuid NOT NULL, "lang" text NOT NULL, "name" text NOT NULL, "address" text, "overview" text, "origin" text NOT NULL, "origin_id" text, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "ck_place_i18n_origin" CHECK ("origin" IN ('tour_en', 'machine', 'curator')), CONSTRAINT "ck_place_i18n_lang" CHECK ("lang" IN ('en', 'ja', 'zh-Hans', 'zh-Hant')), CONSTRAINT "PK_42a28a7fcfecb7c73462df320ae" PRIMARY KEY ("place_id", "lang"))`,
);
await queryRunner.query(
`CREATE TABLE "festival_event" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source_id" text NOT NULL, "kind" text NOT NULL, "name" text NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "lat" double precision, "lng" double precision, "address" text, "place_id" uuid, "fee_text" text, "playtime_text" text, "overview" text, "image_url" text, "image_license" text, "i18n" jsonb, "status" text NOT NULL DEFAULT 'active', "last_seen_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id"), CONSTRAINT "ck_festival_event_period" CHECK ("end_date" >= "start_date"), CONSTRAINT "ck_festival_event_image_license" CHECK ("image_license" IS NULL OR "image_license" IN ('Type1', 'Type3')), CONSTRAINT "ck_festival_event_status" CHECK ("status" IN ('active', 'hidden')), CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event')), CONSTRAINT "PK_01f428b159e675773743bb1d220" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_lat_lng" ON "festival_event" ("lat", "lng") `,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_period" ON "festival_event" ("start_date", "end_date") `,
);
await queryRunner.query(
`ALTER TABLE "place_curation" ADD CONSTRAINT "FK_645f6298438801472c60ce4195c" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "place_i18n" ADD CONSTRAINT "FK_add719bd193db01b37fc0486c13" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "FK_9359619412cd3d514d041894f3c" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(`CREATE VIEW "place_listing" AS
SELECT
p.id,
p.source,
p.source_id,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active') AS is_searchable,
(p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,
CASE
WHEN p.status <> 'active' THEN NULL
WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'public',
'VIEW',
'place_listing',
"SELECT\n p.id,\n p.source,\n p.source_id,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active') AS is_searchable,\n (p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN p.status <> 'active' THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id",
],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'place_listing', 'public'],
);
await queryRunner.query(`DROP VIEW "place_listing"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "FK_9359619412cd3d514d041894f3c"`,
);
await queryRunner.query(
`ALTER TABLE "place_i18n" DROP CONSTRAINT "FK_add719bd193db01b37fc0486c13"`,
);
await queryRunner.query(
`ALTER TABLE "place_curation" DROP CONSTRAINT "FK_645f6298438801472c60ce4195c"`,
);
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_period"`);
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_lat_lng"`);
await queryRunner.query(`DROP TABLE "festival_event"`);
await queryRunner.query(`DROP TABLE "place_i18n"`);
await queryRunner.query(
`DROP INDEX "public"."ix_place_sync_run_job_started_at"`,
);
await queryRunner.query(`DROP TABLE "place_sync_run"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_lat_lng"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_category"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_status"`);
await queryRunner.query(`DROP TABLE "place"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_curation_verdict"`);
await queryRunner.query(
`DROP INDEX "public"."ix_place_curation_recheck_due_at"`,
);
await queryRunner.query(`DROP TABLE "place_curation"`);
}
}
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 관광공사 동기화 준비:
* - source_type: 관광타입(contenttypeid) — 소개정보(detailIntro2) 조회에 필요
* - detail_synced_at: 상세를 받은 시각 — 하루 한도로 나뉜 초기 적재를 이어서 받는다
* - 분류에 'other'(교육시설·연회장 등) 추가
*
* ⚠️ CHECK 제약 변경은 migration:generate 가 감지하지 못해(이름이 같으면 같은 제약으로 본다) 직접 적었다.
*/
export class AddPlaceSourceTypeAndDetailSync1793599990257 implements MigrationInterface {
name = 'AddPlaceSourceTypeAndDetailSync1793599990257';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "place" ADD "source_type" text`);
await queryRunner.query(
`ALTER TABLE "place" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging', 'other'))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 'other' 로 들어간 장소가 있으면 되돌리기 전에 정리해야 한다(제약 위반으로 실패한다)
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging'))`,
);
await queryRunner.query(
`ALTER TABLE "place" DROP COLUMN "detail_synced_at"`,
);
await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "source_type"`);
}
}
@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlaceMergeAndListingFlags1793606583333 implements MigrationInterface {
name = 'AddPlaceMergeAndListingFlags1793606583333';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'place_listing', 'public'],
);
await queryRunner.query(`DROP VIEW "place_listing"`);
await queryRunner.query(`ALTER TABLE "place" ADD "merged_into_id" uuid`);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "FK_d525a5b0e0dccec5c1165637dc7" FOREIGN KEY ("merged_into_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(`CREATE VIEW "place_listing" AS
SELECT
p.id,
p.source,
p.source_id,
p.source_category,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active' AND p.merged_into_id IS NULL) AS is_searchable,
COALESCE(p.source_category = 'SH040300' AND p.name !~ '^(현대백화점|더현대|롯데백화점|대구백화점|신세계|대구신세계|동아백화점)', false) AS is_brand_store,
(p.status = 'active' AND p.merged_into_id IS NULL AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,
CASE
WHEN NOT (p.status = 'active' AND p.merged_into_id IS NULL) THEN NULL
WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'public',
'VIEW',
'place_listing',
"SELECT\n p.id,\n p.source,\n p.source_id,\n p.source_category,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active' AND p.merged_into_id IS NULL) AS is_searchable,\n COALESCE(p.source_category = 'SH040300' AND p.name !~ '^(현대백화점|더현대|롯데백화점|대구백화점|신세계|대구신세계|동아백화점)', false) AS is_brand_store,\n (p.status = 'active' AND p.merged_into_id IS NULL AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN NOT (p.status = 'active' AND p.merged_into_id IS NULL) THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id",
],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'place_listing', 'public'],
);
await queryRunner.query(`DROP VIEW "place_listing"`);
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "FK_d525a5b0e0dccec5c1165637dc7"`,
);
await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "merged_into_id"`);
await queryRunner.query(`CREATE VIEW "place_listing" AS SELECT
p.id,
p.source,
p.source_id,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active') AS is_searchable,
(p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,
CASE
WHEN p.status <> 'active' THEN NULL
WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id`);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'public',
'VIEW',
'place_listing',
"SELECT\n p.id,\n p.source,\n p.source_id,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active') AS is_searchable,\n (p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN p.status <> 'active' THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id",
],
);
}
}
@@ -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,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 행사 출처를 여럿 받을 준비(KOPIS 공연·한국문화정보원 전시 등):
* - festival_event.source + (source, source_id) 유니크 — 관광공사 contentid 단독 유니크였다
* - 장르·예매 링크·공연장(event_venue) — KOPIS 공연은 좌표가 공연장 상세에만 있다
* - 행사 종류에 'exhibition', 동기화 작업에 'kopis'·'kcisa' 추가
*
* ⚠️ CHECK 제약 변경(ck_festival_event_kind·ck_place_sync_run_job)은 migration:generate 가 감지하지 못해 직접 적었다.
* 기존 행사는 기본값으로 source='tour' 가 된다.
*/
export class AddEventSourceAndVenue1793704967449 implements MigrationInterface {
name = 'AddEventSourceAndVenue1793704967449';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "event_venue" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "name" text NOT NULL, "address" text, "lat" double precision, "lng" double precision, "phone" text, "homepage" text, "synced_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_event_venue_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_event_venue_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca')), CONSTRAINT "PK_128347780df4ef90d1426da6c77" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source" text NOT NULL DEFAULT 'tour'`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "genre" text`);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "booking_url" text`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "venue_id" uuid`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "uq_festival_event_source" UNIQUE ("source", "source_id")`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "FK_93324676cbe20bf271722f548b0" FOREIGN KEY ("venue_id") REFERENCES "event_venue"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
// 직접 적은 CHECK 제약 변경
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event', 'exhibition'))`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure', 'kopis', 'kcisa'))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 되돌리기 전에 kopis·kcisa 실행 기록과 전시·다른 출처 행사를 정리해야 한다(제약 위반으로 실패한다)
await queryRunner.query(
`ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "FK_93324676cbe20bf271722f548b0"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "uq_festival_event_source"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_source"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id")`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "venue_id"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "booking_url"`,
);
await queryRunner.query(`ALTER TABLE "festival_event" DROP COLUMN "genre"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source"`,
);
await queryRunner.query(`DROP TABLE "event_venue"`);
}
}
@@ -0,0 +1,105 @@
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: '장르(연극·뮤지컬·클래식·전시 …)',
nullable: true,
example: '뮤지컬',
})
genre!: string | null;
@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: '예매·관람 안내 링크', nullable: true })
bookingUrl!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -0,0 +1,109 @@
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;
/** 근처 행사 반경(m) */
export const EVENT_RADIUS_DEFAULT_M = 5_000;
export const EVENT_RADIUS_MIN_M = 100;
/** 대구 근교(경북) 행사까지 거리순으로 보려면 100km 이상이 필요하다(포항 약 85km · 청송 약 100km) */
export const EVENT_RADIUS_MAX_M = 150_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 행사 · exhibition 전시',
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;
}
@@ -0,0 +1,120 @@
import { ApiProperty } from '@nestjs/swagger';
import { PET_SIZES } from '../place.constants';
import type { PetSize } from '../place.constants';
import { PlaceListItemDto } from './place-list-item.dto';
const HOURS_STATUSES = ['parsed', 'always_open', 'varies', 'unknown'] as const;
export class PlaceHoursDto {
@ApiProperty({
description:
'영업시간 원문(운영자 확인 값이 있으면 그것, 없으면 공공데이터)',
nullable: true,
example: '11:00~21:00 (준비 시간 15:30~17:00)',
})
text!: string | null;
@ApiProperty({
description: '쉬는 날 원문',
nullable: true,
example: '매주 월요일',
})
restText!: string | null;
@ApiProperty({
description:
'해석 결과 — varies(점포별 상이)·unknown 이면 시간을 단정하지 않는다',
enum: HOURS_STATUSES,
})
status!: (typeof HOURS_STATUSES)[number];
}
export class PlacePetDto {
@ApiProperty({
description: '반려견 크기(운영자 확인)',
enum: PET_SIZES,
isArray: true,
nullable: true,
})
sizes!: PetSize[] | null;
@ApiProperty({
description:
'조건 — 운영자 확인 값 또는 공공데이터 원문(예: "목줄 착용", "켄넬 필수")',
nullable: true,
example: '전 견종 동반 가능 · 목줄 착용',
})
conditions!: string | null;
}
export class PlaceCurationInfoDto {
@ApiProperty({
description: '추천 이유(운영자가 직접 쓴 글)',
nullable: true,
example: '골목 벽화를 따라 30분 산책하기 좋아요.',
})
reason!: string | null;
@ApiProperty({ description: '업체 공식 채널', nullable: true })
officialUrl!: string | null;
}
export class PlaceAttributionDto {
@ApiProperty({
description: '정보 출처 표기',
example: '한국관광공사',
})
provider!: string;
@ApiProperty({
description:
'원본 기준 시점(관광공사 수정일 YYYY-MM-DD · 상가정보 기준년월 YYYYMM)',
nullable: true,
example: '2026-10-15',
})
asOf!: string | null;
}
/** 장소 상세 — 목록 필드에 상세 정보를 더한다 */
export class PlaceDetailDto extends PlaceListItemDto {
@ApiProperty({ description: '지번 주소', nullable: true })
addressJibun!: string | null;
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '홈페이지', nullable: true })
homepage!: string | null;
@ApiProperty({ description: '소개(공공데이터)', nullable: true })
overview!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@ApiProperty({ type: PlaceHoursDto })
hours!: PlaceHoursDto;
@ApiProperty({ type: PlacePetDto })
pet!: PlacePetDto;
@ApiProperty({
description: '이용 안내(입장료·주차·문의처·대표 메뉴 등 공공데이터 원문)',
type: 'object',
additionalProperties: { type: 'string' },
nullable: true,
example: { fee: '무료', parking: '가능' },
})
extra!: Record<string, string> | null;
@ApiProperty({
type: PlaceCurationInfoDto,
nullable: true,
description: '운영자 추천 정보 — 추천(O)하고 확인한 장소만',
})
curation!: PlaceCurationInfoDto | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -0,0 +1,128 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
IMAGE_LICENSES,
PET_POLICIES,
PLACE_CATEGORIES,
PLACE_SETTINGS,
PLACE_SOURCES,
} from '../place.constants';
import type {
ImageLicense,
PetPolicy,
PlaceCategory,
PlaceSetting,
PlaceSource,
} from '../place.constants';
/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */
const VALUE_ORIGINS = ['curation', 'public', 'unknown'] as const;
export type ValueOriginDto = (typeof VALUE_ORIGINS)[number];
/** 검색·지도 목록의 장소 한 건 */
export class PlaceListItemDto {
@ApiProperty({
description: '장소 id',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
id!: string;
@ApiProperty({ description: '이름', example: '김광석다시그리기길' })
name!: string;
@ApiProperty({ description: '지점명', nullable: true, example: null })
branchName!: string | null;
@ApiProperty({
description: '분류',
enum: PLACE_CATEGORIES,
example: 'attraction',
})
category!: PlaceCategory;
@ApiProperty({
description: '데이터 출처(tour 한국관광공사 · store 소상공인 상가정보)',
enum: PLACE_SOURCES,
})
source!: PlaceSource;
@ApiProperty({
description: '주소',
nullable: true,
example: '대구광역시 중구 달구벌대로 2238',
})
address!: string | null;
@ApiProperty({ description: '위도', example: 35.8597 })
lat!: number;
@ApiProperty({ description: '경도', example: 128.6067 })
lng!: number;
@ApiProperty({
description: '기준 좌표에서의 거리(m). lat·lng 없이 조회하면 null',
nullable: true,
example: 1057,
})
distanceM!: number | null;
@ApiProperty({ description: '작은 사진 URL', nullable: true })
imageThumbUrl!: string | null;
@ApiProperty({
description:
'사진 공공누리 유형 — 화면에 출처를 표시해야 한다(Type3 은 변경 금지)',
enum: IMAGE_LICENSES,
nullable: true,
})
imageLicense!: ImageLicense | null;
@ApiProperty({
description:
'영업시간 출처(curation 운영자 확인 · public 공공데이터 · unknown 미확인)',
enum: VALUE_ORIGINS,
})
hoursOrigin!: ValueOriginDto;
@ApiProperty({
description:
'지금(한국 시간) 영업 중인지. 알 수 없으면 null — "영업시간 미확인 · 방문 전 확인"',
nullable: true,
example: true,
})
openNow!: boolean | null;
@ApiProperty({
description: '반려동물 동반',
enum: PET_POLICIES,
example: 'allowed',
})
petPolicy!: PetPolicy;
@ApiProperty({ description: '반려동물 정보 출처', enum: VALUE_ORIGINS })
petOrigin!: ValueOriginDto;
@ApiProperty({
description: '실내/실외(운영자가 확인한 장소만)',
enum: PLACE_SETTINGS,
nullable: true,
})
setting!: PlaceSetting | null;
@ApiProperty({
description: '운영자 확인일(YYYY-MM-DD). 확인하지 않았으면 null',
nullable: true,
example: null,
})
verifiedAt!: string | null;
}
/** 장소 목록 응답 본문(인터셉터가 { success, data } 로 한 번 더 감싼다) */
export class PlaceListResponseDto {
@ApiProperty({ type: [PlaceListItemDto] })
items!: PlaceListItemDto[];
@ApiProperty({ type: PageInfoDto })
pageInfo!: PageInfoDto;
}
@@ -0,0 +1,126 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
IsString,
Length,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { PLACE_CATEGORIES } from '../place.constants';
import type { PlaceCategory } from '../place.constants';
/** 반경 기본값·한도(m) */
export const PLACE_RADIUS_DEFAULT_M = 1_500;
export const PLACE_RADIUS_MIN_M = 100;
export const PLACE_RADIUS_MAX_M = 20_000;
/** 쿼리 문자열 'true'/'false' → boolean. 그 밖의 값은 그대로 두어 검증에서 걸리게 한다 */
const toBoolean = ({ value }: { value: unknown }): unknown => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
};
/** 'cafe,food' → ['cafe', 'food'] (같은 키를 여러 번 보내도 된다) */
const toList = ({ value }: { value: unknown }): unknown =>
typeof value === 'string'
? value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: value;
/** 장소 검색·근처 목록 조건 */
export class PlaceSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'이름 검색어(1~50자). 있으면 사후면세점 브랜드 매장도 검색된다',
example: '김광석',
})
@IsOptional()
@IsString({ message: '검색어는 문자열로 입력해 주세요.' })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@Length(1, 50, { message: '검색어는 1~50자로 입력해 주세요.' })
q?: string;
@ApiPropertyOptional({
description: '분류(쉼표로 여러 개)',
enum: PLACE_CATEGORIES,
isArray: true,
example: 'cafe,food',
})
@IsOptional()
@Transform(toList)
@IsArray({ message: '분류 값이 올바르지 않습니다.' })
@IsIn(PLACE_CATEGORIES, {
each: true,
message: '분류 값이 올바르지 않습니다.',
})
category?: PlaceCategory[];
@ApiPropertyOptional({
description: '기준 위도 — lng 와 함께 보내면 거리순으로 반경 안만 찾는다',
example: 35.865,
})
@ValidateIf(
(o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLatitude({ message: '위도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.' })
lat?: number;
@ApiPropertyOptional({ description: '기준 경도', example: 128.5966 })
@ValidateIf(
(o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLongitude({
message: '경도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.',
})
lng?: number;
@ApiPropertyOptional({
description: `반경(m, ${PLACE_RADIUS_MIN_M}~${PLACE_RADIUS_MAX_M}). lat·lng 가 있을 때만 쓴다`,
default: PLACE_RADIUS_DEFAULT_M,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '반경은 정수(m)로 입력해 주세요.' })
@Min(PLACE_RADIUS_MIN_M, {
message: `반경은 ${PLACE_RADIUS_MIN_M}m 이상이어야 합니다.`,
})
@Max(PLACE_RADIUS_MAX_M, {
message: `반경은 ${PLACE_RADIUS_MAX_M}m 이하여야 합니다.`,
})
radius?: number;
@ApiPropertyOptional({
description: '반려동물 동반 가능(전 구역·일부 구역) 장소만',
example: true,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'pet 은 true 또는 false 로 입력해 주세요.' })
pet?: boolean;
@ApiPropertyOptional({
description: '실내(실내·실내외) 장소만 — 운영자가 확인한 장소만 해당한다',
example: false,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' })
indoor?: boolean;
}
@@ -0,0 +1,60 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { EVENT_SOURCES, sqlIn } from '../place.constants';
import type { EventSource } from '../place.constants';
/**
* 공연장·전시장 — 공연 목록에는 좌표가 없고 공연장 상세에만 있는 출처(KOPIS 공연시설 mt10id)를 위해 따로 둔다.
* 새 공연장이 나올 때만 상세를 부르고, 한 달에 한 번 갱신한다. 좌표는 시설 단위다(홀이 여러 개여도 하나).
*/
@Entity()
@Unique('uq_event_venue_source', ['source', 'sourceId'])
@Check('ck_event_venue_source', sqlIn('source', EVENT_SOURCES))
export class EventVenue {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
source!: EventSource;
/** 원본 시설 id — KOPIS mt10id 등 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
address!: string | null;
/** 좌표가 없는 시설도 있다(그 공연은 근처 조회에서만 빠진다) */
@Column({ type: 'double precision', nullable: true })
lat!: number | null;
@Column({ type: 'double precision', nullable: true })
lng!: number | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
/** 시설 상세를 받은 시각 */
@Column({ type: 'timestamptz', nullable: true })
syncedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,168 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import {
EVENT_KINDS,
EVENT_SOURCES,
EVENT_STATUSES,
IMAGE_LICENSES,
sqlIn,
} from '../place.constants';
import type {
EventKind,
EventSource,
EventStatus,
ImageLicense,
} from '../place.constants';
import { EventVenue } from './event-venue.entity';
import { Place } from './place.entity';
/**
* 축제·공연·행사·전시. 출처는 관광공사(searchFestival2)·KOPIS(공연)·한국문화정보원(전시) 등이다.
* 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "상시 프로그램 제외"(종류별 기간 상한, EVENT_MAX_SPAN_DAYS)는 저장할 때가 아니라 조회할 때 적용한다.
*/
@Entity({ name: 'festival_event' })
@Unique('uq_festival_event_source', ['source', 'sourceId'])
@Index('ix_festival_event_period', ['startDate', 'endDate'])
@Index('ix_festival_event_lat_lng', ['lat', 'lng'])
@Index('ix_festival_event_region', ['ldongRegnCd'])
@Check('ck_festival_event_source', sqlIn('source', EVENT_SOURCES))
@Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS))
@Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES))
@Check(
'ck_festival_event_image_license',
sqlIn('image_license', IMAGE_LICENSES, true),
)
@Check('ck_festival_event_period', `"end_date" >= "start_date"`)
export class FestivalEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text', default: 'tour' })
source!: EventSource;
/** 원본 id — 관광공사 contentid · KOPIS mt20id · 문화정보원 seq. (source, sourceId) 가 유니크 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
kind!: EventKind;
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제 · KOPIS 장르코드 · 문화정보원 realmCode) */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
/** 장르 표시용(연극·뮤지컬·클래식·전시 …) */
@Column({ type: 'text', nullable: true })
genre!: string | null;
/** 예매·관람 안내 링크 */
@Column({ type: 'text', nullable: true })
bookingUrl!: string | null;
/** 공연장·전시장(좌표를 공연장에서 얻는 출처가 있다 — KOPIS 공연시설) */
@Column({ type: 'uuid', nullable: true })
venueId!: string | null;
@ManyToOne(() => EventVenue, { nullable: true })
@JoinColumn({ name: 'venue_id' })
venue?: EventVenue | null;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'date' })
startDate!: string;
@Column({ type: 'date' })
endDate!: string;
@Column({ type: 'double precision', nullable: true })
lat!: number | null;
@Column({ type: 'double precision', nullable: true })
lng!: number | null;
@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;
@ManyToOne(() => Place, { nullable: true })
@JoinColumn({ name: 'place_id' })
place?: Place | null;
/** 요금 — 관광공사 usetimefestival 은 이름과 달리 요금이다 */
@Column({ type: 'text', nullable: true })
feeText!: string | null;
/** 공연·운영 시간(playtime) */
@Column({ type: 'text', nullable: true })
playtimeText!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageThumbUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
/** 언어별 텍스트 { en: { name, overview } } */
@Column({ type: 'jsonb', nullable: true })
i18n!: Record<string, Record<string, string>> | null;
@Column({ type: 'text', default: 'active' })
status!: EventStatus;
@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;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,130 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
CURATION_VERDICTS,
PET_POLICIES,
PET_SIZES,
PLACE_SETTINGS,
VERIFY_METHODS,
sqlIn,
sqlValues,
} from '../place.constants';
import type {
CurationVerdict,
PetPolicy,
PetSize,
PlaceSetting,
VerifyMethod,
} from '../place.constants';
import { Place } from './place.entity';
/**
* 운영자가 확인한 장소 정보 (place 1 : 0..1). 큐레이션 표의 오른쪽 칸이 그대로 옮겨 온다.
* 동기화는 이 테이블을 읽지도 쓰지도 않는다.
* 카카오·네이버에서 본 정보는 어떤 칸에도 넣지 않는다 — 확인 근거는 업체 본인 채널·전화·방문뿐이다.
*/
@Entity()
@Index('ix_place_curation_verdict', ['verdict'])
@Index('ix_place_curation_recheck_due_at', ['recheckDueAt'])
@Check('ck_place_curation_verdict', sqlIn('verdict', CURATION_VERDICTS))
@Check('ck_place_curation_pet_policy', sqlIn('pet_policy', PET_POLICIES))
@Check(
'ck_place_curation_pet_sizes',
`"pet_sizes" <@ ARRAY[${sqlValues(PET_SIZES)}]::text[]`,
)
@Check('ck_place_curation_setting', sqlIn('setting', PLACE_SETTINGS, true))
@Check(
'ck_place_curation_verified_method',
sqlIn('verified_method', VERIFY_METHODS, true),
)
// 확인일을 적었다면 무엇으로 확인했는지도 반드시 남긴다
@Check(
'ck_place_curation_verified',
`"verified_at" IS NULL OR "verified_method" IS NOT NULL`,
)
export class PlaceCuration {
@PrimaryColumn({ type: 'uuid' })
placeId!: string;
@OneToOne(() => Place, (place) => place.curation)
@JoinColumn({ name: 'place_id' })
place?: Relation<Place>;
/** 데이트추천(O/X) */
@Column({ type: 'text', default: 'pending' })
verdict!: CurationVerdict;
/** 추천이유 — 큐레이터가 직접 쓴 문장만(리뷰 문장 복사 금지) */
@Column({ type: 'text', nullable: true })
reasonKo!: string | null;
/** 추천이유 번역 { en: … } — 자체 작성 글이라 기계 번역 가능 */
@Column({ type: 'jsonb', nullable: true })
reasonI18n!: Record<string, string> | null;
/** 영업시간(확인) 원문 */
@Column({ type: 'text', nullable: true })
hoursText!: string | null;
/** 휴무(확인) 원문 */
@Column({ type: 'text', nullable: true })
restText!: string | null;
/** hoursText·restText 를 파서로 해석한 결과 */
@Column({ type: 'jsonb', nullable: true })
hours!: ParsedOpeningHours | null;
@Column({ type: 'text', default: 'unknown' })
petPolicy!: PetPolicy;
@Column({ type: 'text', array: true, default: () => `'{}'` })
petSizes!: PetSize[];
/** "테라스석만", "켄넬 필수" 등 */
@Column({ type: 'text', nullable: true })
petConditions!: string | null;
/** 실내/실외 — 비 오는 날 실내 우선 노출에 쓴다 */
@Column({ type: 'text', nullable: true })
setting!: PlaceSetting | null;
/** 업체 본인 공식 채널(인스타·홈페이지·블로그) */
@Column({ type: 'text', nullable: true })
officialUrl!: string | null;
@Column({ type: 'text', nullable: true })
verifiedMethod!: VerifyMethod | null;
/** 확인일 — 비어 있으면 영업시간·반려동물 값은 앱에 쓰지 않는다(place_listing 뷰) */
@Column({ type: 'date', nullable: true })
verifiedAt!: string | null;
@Column({ type: 'text', nullable: true })
verifiedBy!: string | null;
/** 다시 확인할 날짜(기본: 확인일 + 6개월) */
@Column({ type: 'date', nullable: true })
recheckDueAt!: string | null;
/** 내부 메모 — API 로 내보내지 않는다 */
@Column({ type: 'text', nullable: true })
memo!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,51 @@
import {
Check,
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { I18N_LANGS, I18N_ORIGINS, sqlIn } from '../place.constants';
import type { I18nLang, I18nOrigin } from '../place.constants';
import { Place } from './place.entity';
/**
* 장소의 언어별 표시 텍스트. 한국어는 place 본체에 있다.
* 관광공사 영문은 국문과 contentid 가 달라 좌표·분류로 맞춰 붙인다(origin=tour_en, originId=영문 contentid).
*/
@Entity()
@Check('ck_place_i18n_lang', sqlIn('lang', I18N_LANGS))
@Check('ck_place_i18n_origin', sqlIn('origin', I18N_ORIGINS))
export class PlaceI18n {
@PrimaryColumn({ type: 'uuid' })
placeId!: string;
@PrimaryColumn({ type: 'text' })
lang!: I18nLang;
@ManyToOne(() => Place)
@JoinColumn({ name: 'place_id' })
place?: Place;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
address!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
@Column({ type: 'text' })
origin!: I18nOrigin;
/** 관광공사 영문 contentid 등 원본 식별자 */
@Column({ type: 'text', nullable: true })
originId!: string | null;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,180 @@
import { ViewColumn, ViewEntity } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
AI_TIER2_CATEGORIES,
BRAND_STORE_CATEGORY,
MALL_NAME_PREFIXES,
sqlAlternation,
sqlValues,
} from '../place.constants';
import type {
CurationVerdict,
ImageLicense,
PetPolicy,
PetSize,
PlaceCategory,
PlaceSetting,
PlaceSource,
PlaceStatus,
} from '../place.constants';
/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */
export type ValueOrigin = 'curation' | 'public' | 'unknown';
// 큐레이션 값은 확인일이 있을 때만 쓴다 — 확인 안 된 값을 확인된 것처럼 보이지 않게 한다
const CURATED_HOURS = `c.verified_at IS NOT NULL AND c.hours IS NOT NULL`;
const PUBLIC_HOURS = `p.public_hours->>'status' IN ('parsed', 'always_open')`;
const CURATED_PET = `c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown'`;
const PUBLIC_PET = `p.public_pet_policy IS NOT NULL`;
const HOME_PICK = `c.verdict = 'recommend' AND c.verified_at IS NOT NULL`;
// 노출 중 = 숨김·폐업 추정이 아니고, 다른 출처의 같은 가게로 병합되지도 않은 장소
const LISTED = `p.status = 'active' AND p.merged_into_id IS NULL`;
// 사후면세점 브랜드 매장(백화점·쇼핑몰 자체는 제외) — 목록·필터에서 빼고 이름 검색에만 낸다
const BRAND_STORE = `p.source_category = ${sqlValues([BRAND_STORE_CATEGORY])} AND p.name !~ '^(${sqlAlternation(MALL_NAME_PREFIXES)})'`;
/**
* "앱이 보는 값" — 공공 값과 큐레이션 값을 합치고 노출 플래그를 계산한다.
* 우선순위(큐레이션 > 공공 > 미확인)와 노출 규칙은 **여기서만** 정한다. API·AI 는 이 뷰를 읽는다.
* 노출 규칙(2026-11-02 합의, docs/design/place-db.md §3):
* - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함, 병합된 중복은 제외)
* - is_brand_store: 사후면세점 브랜드 매장 — 조회 API 가 목록·필터에서는 빼고 이름 검색에만 낸다
* - is_home_pick: 추천(O) + 확인일이 있는 장소만 홈 동네 추천에 나온다
* - ai_tier: 1 = 추천(O) + 확인 / 2 = 관광공사 장소(코스 분류, X 아님) / NULL = 그 외 — 확인 안 된 상가정보 매장은 AI 후보가 아니다
*/
export const PLACE_LISTING_SQL = `
SELECT
p.id,
p.source,
p.source_id,
p.source_category,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN ${CURATED_HOURS} THEN c.hours WHEN ${PUBLIC_HOURS} THEN p.public_hours END AS hours,
CASE WHEN ${CURATED_HOURS} THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN ${CURATED_HOURS} THEN 'curation' WHEN ${PUBLIC_HOURS} THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN ${CURATED_PET} THEN c.pet_policy WHEN ${PUBLIC_PET} THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN ${CURATED_PET} THEN 'curation' WHEN ${PUBLIC_PET} THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(${LISTED}) AS is_searchable,
COALESCE(${BRAND_STORE}, false) AS is_brand_store,
(${LISTED} AND COALESCE(${HOME_PICK}, false)) AS is_home_pick,
CASE
WHEN NOT (${LISTED}) THEN NULL
WHEN ${HOME_PICK} THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN (${sqlValues(AI_TIER2_CATEGORIES)}) THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`;
@ViewEntity({ name: 'place_listing', expression: PLACE_LISTING_SQL })
export class PlaceListing {
@ViewColumn()
id!: string;
@ViewColumn()
source!: PlaceSource;
@ViewColumn()
sourceId!: string;
/** 원본 분류 코드(lclsSystm3 · indsSclsCd) */
@ViewColumn()
sourceCategory!: string | null;
@ViewColumn()
category!: PlaceCategory;
@ViewColumn()
name!: string;
@ViewColumn()
branchName!: string | null;
@ViewColumn()
addressRoad!: string | null;
@ViewColumn()
lat!: number;
@ViewColumn()
lng!: number;
@ViewColumn()
ldongRegnCd!: string | null;
@ViewColumn()
ldongSignguCd!: string | null;
@ViewColumn()
imageUrl!: string | null;
@ViewColumn()
imageThumbUrl!: string | null;
@ViewColumn()
imageLicense!: ImageLicense | null;
@ViewColumn()
status!: PlaceStatus;
@ViewColumn()
verdict!: CurationVerdict | null;
@ViewColumn()
verifiedAt!: string | null;
@ViewColumn()
setting!: PlaceSetting | null;
/** 큐레이션(확인됨) → 공공(해석 성공) → null */
@ViewColumn()
hours!: ParsedOpeningHours | null;
@ViewColumn()
hoursText!: string | null;
@ViewColumn()
hoursOrigin!: ValueOrigin;
@ViewColumn()
petPolicy!: PetPolicy;
@ViewColumn()
petOrigin!: ValueOrigin;
@ViewColumn()
petSizes!: PetSize[] | null;
@ViewColumn()
petConditions!: string | null;
@ViewColumn()
isSearchable!: boolean;
@ViewColumn()
isBrandStore!: boolean;
@ViewColumn()
isHomePick!: boolean;
/** 1: 큐레이션 추천 · 2: 관광공사 장소 · null: AI 후보 아님 */
@ViewColumn()
aiTier!: 1 | 2 | null;
}
@@ -0,0 +1,56 @@
import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { SYNC_JOBS, sqlIn } from '../place.constants';
import type { SyncJob } from '../place.constants';
/**
* 동기화 실행 기록 — "언제 받은 데이터인가"에 답하고, 오퍼레이션별 하루 호출 한도를 관리한다.
* 키나 요청 URL 은 남기지 않는다(오류 문구도 키를 가린 뒤 저장).
*/
@Entity()
@Index('ix_place_sync_run_job_started_at', ['job', 'startedAt'])
@Check('ck_place_sync_run_job', sqlIn('job', SYNC_JOBS))
export class PlaceSyncRun {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
job!: SyncJob;
/** 범위 — 예: 법정동 시·도 '27', 행정동 목록 등 */
@Column({ type: 'text', nullable: true })
scope!: string | null;
@Column({ type: 'timestamptz' })
startedAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
@Column({ type: 'integer', default: 0 })
fetched!: number;
@Column({ type: 'integer', default: 0 })
inserted!: number;
@Column({ type: 'integer', default: 0 })
updated!: number;
@Column({ type: 'integer', default: 0 })
hidden!: number;
@Column({ type: 'integer', default: 0 })
gone!: number;
/** 오퍼레이션별 호출 수 { detailIntro2: 412, … } */
@Column({ type: 'jsonb', default: () => `'{}'` })
calls!: Record<string, number>;
/** 오류 앞 20건(키는 가린 뒤 저장) */
@Column({ type: 'jsonb', default: () => `'[]'` })
errors!: string[];
/** 원본 기준 시점(상가정보 stdrYm 등) */
@Column({ type: 'text', nullable: true })
snapshot!: string | null;
}
@@ -0,0 +1,191 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
IMAGE_LICENSES,
PLACE_CATEGORIES,
PLACE_SOURCES,
PLACE_STATUSES,
PUBLIC_PET_POLICIES,
sqlIn,
} from '../place.constants';
import type {
ImageLicense,
PlaceCategory,
PlaceSource,
PlaceStatus,
PublicPetPolicy,
} from '../place.constants';
import { PlaceCuration } from './place-curation.entity';
/**
* 공공데이터 장소 — 출처 1건이 1행이다. 동기화 명령이 자동으로 갱신한다.
* 운영자가 확인한 값은 place_curation 에 따로 두며, 동기화는 그 테이블을 건드리지 않는다.
* 장소는 지우지 않는다(큐레이션·코스가 가리킬 수 있다) — 사라지면 status 를 바꾼다.
*/
@Entity()
@Unique('uq_place_source', ['source', 'sourceId'])
@Index('ix_place_lat_lng', ['lat', 'lng'])
@Index('ix_place_category', ['category'])
@Index('ix_place_status', ['status'])
@Check('ck_place_source', sqlIn('source', PLACE_SOURCES))
@Check('ck_place_category', sqlIn('category', PLACE_CATEGORIES))
@Check('ck_place_status', sqlIn('status', PLACE_STATUSES))
@Check(
'ck_place_public_pet_policy',
sqlIn('public_pet_policy', PUBLIC_PET_POLICIES, true),
)
@Check('ck_place_image_license', sqlIn('image_license', IMAGE_LICENSES, true))
@Check(
'ck_place_lat_lng',
`"lat" BETWEEN -90 AND 90 AND "lng" BETWEEN -180 AND 180`,
)
export class Place {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
source!: PlaceSource;
/** 관광공사 contentid · 상가정보 bizesId — (source, sourceId) 가 동기화·큐레이션의 연결 키 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
category!: PlaceCategory;
/** 원본 분류 코드(lclsSystm3 · indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
/** 관광공사 관광타입(contenttypeid: 12 관광지·14 문화시설·39 음식점 …) — 소개정보 조회에 필요하다 */
@Column({ type: 'text', nullable: true })
sourceType!: string | null;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
branchName!: string | null;
@Column({ type: 'text', nullable: true })
addressRoad!: string | null;
@Column({ type: 'text', nullable: true })
addressJibun!: string | null;
/** WGS84. 좌표가 없는 원본은 적재하지 않는다(지도·거리 계산 불가) */
@Column({ type: 'double precision' })
lat!: number;
@Column({ type: 'double precision' })
lng!: number;
/** 법정동 시·도(2자리) — 관광공사 동기화 범위 */
@Column({ type: 'text', nullable: true })
ldongRegnCd!: string | null;
/** 법정동 시·군·구(3자리) */
@Column({ type: 'text', nullable: true })
ldongSignguCd!: string | null;
/** 행정동(상가정보 8자리) — 상가정보 동기화 범위 */
@Column({ type: 'text', nullable: true })
adongCd!: string | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
/** 영업시간·쉬는날 원문(관광공사 유형별 필드) */
@Column({ type: 'text', nullable: true })
publicHoursText!: string | null;
@Column({ type: 'text', nullable: true })
publicRestText!: string | null;
/** 원문을 opening-hours 파서로 해석한 결과 — 동기화 때마다 다시 계산한다 */
@Column({ type: 'jsonb', nullable: true })
publicHours!: ParsedOpeningHours | null;
/** 관광공사 반려동물 동반 정보 원문. 없으면 '미확인'(불가 아님) */
@Column({ type: 'jsonb', nullable: true })
publicPet!: Record<string, string> | null;
/** publicPet 을 동기화 때 요약한 값(전구역 → allowed, 일부구역 → partial) */
@Column({ type: 'text', nullable: true })
publicPetPolicy!: PublicPetPolicy | null;
/** 유형별 부가 정보(입장료·주차 등) 원문 */
@Column({ type: 'jsonb', nullable: true })
publicExtra!: Record<string, string> | null;
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageThumbUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
@Column({ type: 'text', default: 'active' })
status!: PlaceStatus;
/** 관광공사 modifiedtime */
@Column({ type: 'timestamptz', nullable: true })
sourceModifiedAt!: Date | null;
/** 상가정보 기준년월(stdrYm) */
@Column({ type: 'text', nullable: true })
sourceSnapshot!: string | null;
/** 마지막 동기화에서 원본에 있던 시각 */
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
/**
* 상세(개요·영업시간 등)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다.
* 개발 계정 한도(오퍼레이션별 하루 1,000건) 때문에 초기 적재가 며칠에 나뉘므로, 이 값으로 이어서 받는다.
*/
@Column({ type: 'timestamptz', nullable: true })
detailSyncedAt!: Date | null;
/**
* 같은 가게를 다른 출처가 이미 가지고 있으면 그 장소의 id(상가정보 → 관광공사).
* 값이 있으면 검색·추천에 내보내지 않고, 상세로 들어오면 병합 대상 장소를 보여 준다. 동기화가 끝날 때마다 다시 계산한다.
*/
@Column({ type: 'uuid', nullable: true })
mergedIntoId!: string | null;
@ManyToOne(() => Place, { nullable: true })
@JoinColumn({ name: 'merged_into_id' })
mergedInto?: Relation<Place> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@OneToOne(() => PlaceCuration, (curation) => curation.place)
curation?: Relation<PlaceCuration> | null;
}
@@ -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,200 @@
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_RADIUS_DEFAULT_M,
EVENT_RANGE_DEFAULT_DAYS,
EVENT_RANGE_MAX_DAYS,
EventSearchQueryDto,
} from './dto/event-search-query.dto';
import { FestivalEvent } from './entities/festival-event.entity';
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
import type { EventSource } from './place.constants';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
const DAY_MS = 86_400_000;
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/** 출처 표기(행사 상세 하단·앱 정보 화면) */
const PROVIDER_LABEL: Record<EventSource, string> = {
tour: '한국관광공사',
kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)',
kcisa: '한국문화정보원 문화포털',
dgfca: '대구문화예술진흥원',
};
/** 종류별 기간 상한 SQL — 상수(EVENT_MAX_SPAN_DAYS)로만 만든다 */
const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS)
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
.join(' ')} END`;
/** 'YYYY-MM-DD' 두 날짜 사이 일수(b − a). 형식이 틀리거나 없는 날짜면 NaN */
function daysBetween(a: string, b: string): number {
const ta = Date.parse(`${a}T00:00:00Z`);
const tb = Date.parse(`${b}T00:00:00Z`);
// '2030-13-01' 처럼 해석이 안 되면 NaN — toISOString 이 예외를 던지지 않게 먼저 거른다
if (Number.isNaN(ta) || Number.isNaN(tb)) return Number.NaN;
const validA = new Date(ta).toISOString().startsWith(a);
const validB = new Date(tb).toISOString().startsWith(b);
return validA && validB ? Math.round((tb - ta) / DAY_MS) : Number.NaN;
}
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) <= ${MAX_SPAN_SQL}`);
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,
bookingUrl: event.bookingUrl,
imageUrl: event.imageUrl,
attribution: {
provider: PROVIDER_LABEL[event.source],
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,
genre: e.genre,
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,
};
}
}
@@ -0,0 +1,28 @@
import { publicPetNote } from './pet-note';
describe('publicPetNote', () => {
it('글머리표·줄바꿈을 정리해 한 줄로 잇는다(김광석다시그리기길 실제 원문)', () => {
expect(
publicPetNote({
acmpyTypeCd: '전구역 동반가능',
acmpyPsblCpam: '전 견종 동반 가능',
acmpyNeedMtr: '목줄 착용',
etcAcmpyInfo:
'- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참 및 배변처리 필수',
}),
).toBe(
'전 견종 동반 가능 · 목줄 착용 · 맹견의 경우, 입마개 착용 필수 · 배변봉투 지참 및 배변처리 필수',
);
});
it('같은 문장이 여러 필드에 반복되면 한 번만 쓴다', () => {
expect(
publicPetNote({ acmpyNeedMtr: '목줄 착용', etcAcmpyInfo: '※ 목줄 착용' }),
).toBe('목줄 착용');
});
it('조건 문장이 없으면 null', () => {
expect(publicPetNote({ acmpyTypeCd: '전구역 동반가능' })).toBeNull();
expect(publicPetNote(null)).toBeNull();
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 관광공사 반려동물 동반 정보 원문 → 화면에 보여 줄 조건 문장 (순수 함수).
* 원문에는 글머리표('- ')와 줄바꿈이 섞여 온다(예: "- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참").
*/
/** 조건으로 보여 줄 필드(동반 구분 acmpyTypeCd 는 petPolicy 로 따로 나간다) */
const NOTE_FIELDS = ['acmpyPsblCpam', 'acmpyNeedMtr', 'etcAcmpyInfo'];
/** 줄 앞의 글머리표·번호 표시 */
const BULLET_RE = /^\s*(?:[-*•·※]|\d+[.)])\s*/;
export function publicPetNote(
raw: Record<string, string> | null,
): string | null {
if (!raw) return null;
const parts: string[] = [];
for (const key of NOTE_FIELDS) {
const value = raw[key];
if (typeof value !== 'string') continue;
for (const line of value.split(/\n+/)) {
const text = line.replace(BULLET_RE, '').trim();
if (text !== '' && !parts.includes(text)) parts.push(text);
}
}
return parts.length > 0 ? parts.join(' · ') : null;
}
@@ -0,0 +1,36 @@
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
describe('place cursor', () => {
it('거리·이름 커서를 인코딩했다가 그대로 되돌린다', () => {
const byDistance = encodePlaceCursor({ k: 812.4567, id: ID });
expect(decodePlaceCursor(byDistance, 'number')).toEqual({
k: 812.4567,
id: ID,
});
const byName = encodePlaceCursor({ k: '김광석다시그리기길', id: ID });
expect(decodePlaceCursor(byName, 'string')).toEqual({
k: '김광석다시그리기길',
id: ID,
});
});
it('정렬 방식과 맞지 않는 커서는 받지 않는다', () => {
const byName = encodePlaceCursor({ k: '가', id: ID });
expect(decodePlaceCursor(byName, 'number')).toBeNull();
});
it('형식이 틀린 커서는 받지 않는다', () => {
expect(decodePlaceCursor('아무거나', 'number')).toBeNull();
expect(
decodePlaceCursor(
Buffer.from('{"k":1,"id":"x"}').toString('base64url'),
'number',
),
).toBeNull();
expect(
decodePlaceCursor(Buffer.from('null').toString('base64url'), 'number'),
).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* 장소 목록 커서 (순수 함수) — 정렬 기준값과 id 로 다음 페이지 시작점을 가리킨다(키셋 페이지네이션).
* 거리순이면 k 는 거리(m, 숫자), 이름순이면 k 는 이름(문자열)이다.
*/
export interface PlaceCursor {
k: number | string;
id: string;
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function encodePlaceCursor(cursor: PlaceCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}
/**
* 커서를 해석한다. 형식이 틀리거나 정렬 방식과 맞지 않으면(거리순인데 문자열 등) null.
* null 이면 호출하는 쪽이 VAL_001 로 응답한다.
*/
export function decodePlaceCursor(
raw: string,
kind: 'number' | 'string',
): PlaceCursor | null {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object') return null;
const { k, id } = parsed as Record<string, unknown>;
if (typeof id !== 'string' || !UUID_RE.test(id)) return null;
if (kind === 'number' && typeof k === 'number' && Number.isFinite(k)) {
return { k, id };
}
if (kind === 'string' && typeof k === 'string') return { k, id };
return null;
}
@@ -0,0 +1,35 @@
import {
AI_TIER2_CATEGORIES,
PLACE_CATEGORIES,
sqlIn,
sqlValues,
} from './place.constants';
describe('place.constants', () => {
it('CHECK 제약 식을 코드값 목록으로 만든다', () => {
expect(sqlIn('status', ['active', 'gone'])).toBe(
`"status" IN ('active', 'gone')`,
);
expect(sqlIn('setting', ['indoor'], true)).toBe(
`"setting" IS NULL OR "setting" IN ('indoor')`,
);
});
it('따옴표 같은 문자가 섞인 코드값은 SQL 로 만들지 않고 실패한다', () => {
expect(() => sqlValues(["a'; DROP TABLE place; --"])).toThrow();
});
it('언어 코드처럼 하이픈이 든 값은 허용한다', () => {
expect(sqlValues(['zh-Hans'])).toBe(`'zh-Hans'`);
});
it('숙박·쇼핑은 큐레이션 없이 AI 후보가 되지 않는다', () => {
const tier2: readonly string[] = AI_TIER2_CATEGORIES;
expect(tier2).not.toContain('lodging');
expect(tier2).not.toContain('shopping');
expect(tier2).not.toContain('bar');
for (const c of tier2) {
expect(PLACE_CATEGORIES).toContain(c);
}
});
});
@@ -0,0 +1,191 @@
/**
* 장소 DB 의 코드값 — 엔티티 CHECK 제약·place_listing 뷰·서비스가 이 목록 하나를 공유한다.
* 설계: docs/design/place-db.md
*
* ⚠️ 목록을 바꾸면 CHECK 제약도 바뀌는데, migration:generate 는 이름이 같은 CHECK 제약의 식 변경을
* 감지하지 못한다. 새 마이그레이션에 DROP CONSTRAINT / ADD CONSTRAINT 를 직접 적는다
* (예: 1793599990257-AddPlaceSourceTypeAndDetailSync). 뷰 식 변경은 generate 가 감지한다.
*/
/** 데이터 출처: 관광공사(TourAPI) · 소상공인 상가정보 */
export const PLACE_SOURCES = ['tour', 'store'] as const;
export type PlaceSource = (typeof PLACE_SOURCES)[number];
/**
* 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6, 대응표는 sync/tour-mapper.ts).
* other: 교육시설·연회장처럼 데이트 장소가 아닌 곳 — 검색 '전체'에만 나오고 AI 후보가 아니다.
*/
export const PLACE_CATEGORIES = [
'nature',
'attraction',
'culture',
'leisure',
'food',
'cafe',
'bar',
'shopping',
'lodging',
'other',
] as const;
export type PlaceCategory = (typeof PLACE_CATEGORIES)[number];
/** active: 노출 · hidden: 관광공사 비표출 · gone: 스냅샷에서 사라짐(폐업 추정). 장소는 지우지 않는다 */
export const PLACE_STATUSES = ['active', 'hidden', 'gone'] as const;
export type PlaceStatus = (typeof PLACE_STATUSES)[number];
/** 운영자 판단: 데이트 추천 O · X · 아직 안 봄 */
export const CURATION_VERDICTS = [
'recommend',
'not_recommend',
'pending',
] as const;
export type CurationVerdict = (typeof CURATION_VERDICTS)[number];
/** 반려동물 동반 — 정보가 없으면 '불가'가 아니라 'unknown' 이다 */
export const PET_POLICIES = [
'allowed',
'partial',
'not_allowed',
'unknown',
] as const;
export type PetPolicy = (typeof PET_POLICIES)[number];
/** 관광공사 반려동물 정보는 "동반 가능한 곳"만 담으므로 두 값뿐이다 */
export const PUBLIC_PET_POLICIES = ['allowed', 'partial'] as const;
export type PublicPetPolicy = (typeof PUBLIC_PET_POLICIES)[number];
export const PET_SIZES = ['small', 'medium', 'large'] as const;
export type PetSize = (typeof PET_SIZES)[number];
export const PLACE_SETTINGS = ['indoor', 'outdoor', 'both'] as const;
export type PlaceSetting = (typeof PLACE_SETTINGS)[number];
/** 확인 근거 — 업체 본인 채널·전화·방문만 인정한다(카카오·네이버 정보 금지) */
export const VERIFY_METHODS = ['phone', 'official_channel', 'visit'] as const;
export type VerifyMethod = (typeof VERIFY_METHODS)[number];
/** 한국어는 place 본체에 두고, 나머지 언어만 place_i18n 에 둔다 */
export const I18N_LANGS = ['en', 'ja', 'zh-Hans', 'zh-Hant'] as const;
export type I18nLang = (typeof I18N_LANGS)[number];
export const I18N_ORIGINS = ['tour_en', 'machine', 'curator'] as const;
export type I18nOrigin = (typeof I18N_ORIGINS)[number];
/** 공공누리 유형 — 화면에 출처 표시가 필요하다 */
export const IMAGE_LICENSES = ['Type1', 'Type3'] as const;
export type ImageLicense = (typeof IMAGE_LICENSES)[number];
/** 행사 종류 — 관광공사 lclsSystm2(EV01 축제 · EV02 공연 · EV03 행사) + 전시(KOPIS 장르는 genre 에 따로 둔다) */
export const EVENT_KINDS = [
'festival',
'performance',
'event',
'exhibition',
] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
export const EVENT_STATUSES = ['active', 'hidden'] as const;
/**
* 행사 출처 — tour 한국관광공사 · kopis 공연예술통합전산망 · kcisa 한국문화정보원 · dgfca 대구문화예술진흥원.
* (출처, 원본 id) 가 행사·공연장의 연결 키다.
*/
export const EVENT_SOURCES = ['tour', 'kopis', 'kcisa', 'dgfca'] as const;
export type EventSource = (typeof EVENT_SOURCES)[number];
/**
* 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의는 60일).
* 전시는 몇 달씩 하는 것이 보통이라 길게 잡는다 — 그대로 두면 전시 대부분이 빠진다.
*/
export const EVENT_MAX_SPAN_DAYS: Record<EventKind, number> = {
festival: 60,
performance: 60,
event: 60,
exhibition: 183,
};
export type EventStatus = (typeof EVENT_STATUSES)[number];
export const SYNC_JOBS = [
'tour_kor',
'tour_eng',
'tour_pet',
'store',
'event',
'closure',
'kopis',
'kcisa',
] as const;
export type SyncJob = (typeof SYNC_JOBS)[number];
/**
* AI 2순위 후보가 되는 분류(큐레이션 없이도 관광공사 장소면 후보).
* 숙박·쇼핑은 큐레이션으로 추천(O) 받았을 때만 후보가 된다 — 2026-11-02 합의.
*/
export const AI_TIER2_CATEGORIES = [
'nature',
'attraction',
'culture',
'leisure',
'food',
'cafe',
] as const satisfies readonly PlaceCategory[];
/**
* 사후면세점(관광공사 SH040300) — 2026-11-02 대구 도심 쇼핑 135곳 대부분이 더현대 등에 입점한 브랜드 매장이었다.
* 목록·필터 결과에는 내보내지 않고 이름 검색(q)에만 나온다. 백화점·쇼핑몰 자체는 예외(아래 이름으로 시작).
*/
export const BRAND_STORE_CATEGORY = 'SH040300';
export const MALL_NAME_PREFIXES = [
'현대백화점',
'더현대',
'롯데백화점',
'대구백화점',
'신세계',
'대구신세계',
'동아백화점',
] as const;
/**
* 같은 가게를 관광공사·상가정보가 둘 다 가지고 있을 때(2026-11-02 대구 약 21곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
*/
export const MERGE_TARGET_CATEGORIES = [
'cafe',
'food',
'bar',
] as const satisfies readonly PlaceCategory[];
/** 같은 가게로 볼 최대 거리(m) */
export const MERGE_MAX_DISTANCE_M = 100;
/** 정규식 대안 목록 `a|b|c` — 한글·영문·숫자 단어만 허용한다(뷰 SQL 에 들어간다) */
export function sqlAlternation(words: readonly string[]): string {
for (const w of words) {
if (!/^[가-힣A-Za-z0-9]+$/.test(w)) {
throw new Error(`SQL 정규식 단어에 허용되지 않는 문자가 있습니다: ${w}`);
}
}
return words.join('|');
}
/**
* CHECK 제약·뷰에 넣을 SQL 값 목록 — `'a', 'b'`.
* 코드 상수로만 만들지만, 혹시라도 따옴표가 섞이면 SQL 이 깨지므로 즉시 실패시킨다.
*/
export function sqlValues(values: readonly string[]): string {
for (const v of values) {
if (!/^[A-Za-z0-9_-]+$/.test(v)) {
throw new Error(`SQL 코드값에 허용되지 않는 문자가 있습니다: ${v}`);
}
}
return values.map((v) => `'${v}'`).join(', ');
}
/** `"col" IN (...)` CHECK 식. nullable 이면 NULL 도 허용한다 */
export function sqlIn(
column: string,
values: readonly string[],
nullable = false,
): string {
const inList = `"${column}" IN (${sqlValues(values)})`;
return nullable ? `"${column}" IS NULL OR ${inList}` : inList;
}
@@ -0,0 +1,46 @@
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 { PlaceDetailDto } from './dto/place-detail.dto';
import { PlaceListResponseDto } from './dto/place-list-item.dto';
import { PlaceSearchQueryDto } from './dto/place-search-query.dto';
import { PlaceService } from './place.service';
/**
* 장소 조회 — 로그인 없이 쓴다(둘러보기·검색은 비로그인 허용, 2026-10-28 합의).
* 라우팅과 입출력만 담당하고 조회 규칙은 PlaceService·place_listing 뷰가 정한다.
*/
@ApiTags('Place')
@Controller('places')
export class PlaceController {
constructor(private readonly placeService: PlaceService) {}
@Get()
@ApiOperation({
summary: '장소 검색·근처 목록 (커서 페이지네이션)',
description:
'lat·lng 를 주면 반경 안을 거리순으로, 없으면 이름순으로 준다. 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.',
})
@ApiStandardResponse(PlaceListResponseDto, { description: '장소 목록' })
@ApiErrorResponse('VAL_001', '검색 조건 오류(좌표·반경·분류·커서 형식)')
search(@Query() query: PlaceSearchQueryDto) {
return this.placeService.search(query);
}
@Get(':id')
@ApiOperation({
summary: '장소 상세',
description:
'같은 가게로 병합된 장소 id 로 들어오면 병합 대상 장소를 준다. 숨김·폐업 추정 장소는 찾을 수 없음으로 응답한다.',
})
@ApiStandardResponse(PlaceDetailDto, { description: '장소 상세' })
@ApiErrorResponse('VAL_001', 'id 형식 오류(UUID 가 아님)')
@ApiErrorResponse('RES_001', '장소를 찾을 수 없음')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.placeService.findOne(id);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventVenue } from './entities/event-venue.entity';
import { FestivalEvent } from './entities/festival-event.entity';
import { PlaceCuration } from './entities/place-curation.entity';
import { Place } from './entities/place.entity';
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/)이 맡는다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Place,
PlaceCuration,
PlaceI18n,
PlaceListing,
FestivalEvent,
EventVenue,
PlaceSyncRun,
]),
],
controllers: [PlaceController, FestivalController],
providers: [PlaceService, FestivalService],
exports: [TypeOrmModule, PlaceService, FestivalService],
})
export class PlaceModule {}
+238
View File
@@ -0,0 +1,238 @@
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 { kstClock } from '@/shared/utils/kst';
import { isOpenAt } from '@/shared/utils/opening-hours';
import { escapeLike } from '@/shared/utils/sql-like';
import { PlaceDetailDto } from './dto/place-detail.dto';
import { PlaceListItemDto } from './dto/place-list-item.dto';
import {
PLACE_RADIUS_DEFAULT_M,
PlaceSearchQueryDto,
} from './dto/place-search-query.dto';
import { PlaceListing } from './entities/place-listing.entity';
import { Place } from './entities/place.entity';
import { publicPetNote } from './pet-note';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
/** 출처 표기 */
const PROVIDER_LABEL: Record<Place['source'], string> = {
tour: '한국관광공사',
store: '소상공인시장진흥공단 상가(상권)정보',
};
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* 장소 조회 — 검색·근처 목록과 상세. 노출 규칙·값 우선순위는 place_listing 뷰가 정하고, 여기서는 뷰를 읽기만 한다.
*/
@Injectable()
export class PlaceService {
constructor(
@InjectRepository(PlaceListing)
private readonly listings: Repository<PlaceListing>,
@InjectRepository(Place) private readonly places: Repository<Place>,
) {}
/**
* 검색·근처 목록. 좌표가 있으면 반경 안을 거리순으로, 없으면 이름순으로 준다(키셋 커서).
* 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.
*/
async search(
query: PlaceSearchQueryDto,
now = new Date(),
): Promise<PaginatedResult<PlaceListItemDto>> {
const near =
query.lat !== undefined && query.lng !== undefined
? {
lat: query.lat,
lng: query.lng,
radius: query.radius ?? PLACE_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.listings
.createQueryBuilder('v')
.where('v.is_searchable = true');
if (query.q) {
qb.andWhere('v.name ILIKE :q', { q: `%${escapeLike(query.q)}%` });
} else {
qb.andWhere('v.is_brand_store = false');
}
if (query.category?.length) {
qb.andWhere('v.category IN (:...categories)', {
categories: query.category,
});
}
if (query.pet) {
qb.andWhere('v.pet_policy IN (:...pets)', {
pets: ['allowed', 'partial'],
});
}
if (query.indoor) {
qb.andWhere('v.setting IN (:...settings)', {
settings: ['indoor', 'both'],
});
}
// 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다(그러면 결과 객체가 비어 버린다)
let sortKey = '(v.name)';
if (near) {
sortKey = sqlDistanceM(':lat', ':lng', 'v.lat', 'v.lng');
const box = boundingBox(near.lat, near.lng, near.radius);
qb.andWhere('v.lat BETWEEN :latMin AND :latMax', box)
.andWhere('v.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) {
qb.andWhere(`(${sortKey}, v.id) > (:ck, :cid)`, {
ck: cursor.k,
cid: cursor.id,
});
}
const { entities, raw } = await qb
.addSelect(sortKey, 'sort_key')
.orderBy('sort_key', 'ASC')
.addOrderBy('v.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 clock = kstClock(now);
const distanceAt = (i: number) => (near ? Number(raw[i]?.sort_key) : null);
const items = page.map((v, i) => this.toListItem(v, distanceAt(i), clock));
const last = page.at(-1);
const nextCursor =
hasNext && last
? encodePlaceCursor({
k: near ? (distanceAt(page.length - 1) ?? 0) : last.name,
id: last.id,
})
: null;
return toPaginatedResult(items, { hasNext, nextCursor, total });
}
/**
* 상세. 다른 장소로 병합된 id(같은 가게의 상가정보 쪽)로 들어오면 병합 대상 장소를 보여 준다.
* 없거나 숨김·폐업 추정이면 RES_001.
*/
async findOne(id: string, now = new Date()): Promise<PlaceDetailDto> {
const first = await this.places.findOne({
where: { id },
relations: { curation: true },
});
const place =
first?.mergedIntoId != null
? await this.places.findOne({
where: { id: first.mergedIntoId },
relations: { curation: true },
})
: first;
const listing = place
? await this.listings.findOneBy({ id: place.id })
: null;
if (!place || !listing?.isSearchable) {
throw new BusinessException('RES_001', { context: { placeId: id } });
}
const curation = place.curation ?? null;
const curatedHours = listing.hoursOrigin === 'curation';
const hoursStatus = curatedHours
? curation?.hours?.status
: place.publicHours?.status;
return {
...this.toListItem(listing, null, kstClock(now)),
addressJibun: place.addressJibun,
phone: place.phone,
homepage: place.homepage,
overview: place.overview,
imageUrl: place.imageUrl,
hours: {
text: curatedHours
? (curation?.hoursText ?? null)
: place.publicHoursText,
restText: curatedHours
? (curation?.restText ?? null)
: place.publicRestText,
status: hoursStatus ?? 'unknown',
},
pet: {
sizes: listing.petOrigin === 'curation' ? listing.petSizes : null,
conditions:
listing.petOrigin === 'curation'
? (curation?.petConditions ?? null)
: listing.petOrigin === 'public'
? publicPetNote(place.publicPet)
: null,
},
extra: place.publicExtra,
curation: listing.isHomePick
? {
reason: curation?.reasonKo ?? null,
officialUrl: curation?.officialUrl ?? null,
}
: null,
attribution: {
provider: PROVIDER_LABEL[place.source],
asOf:
place.source === 'tour'
? place.sourceModifiedAt
? new Date(place.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
.slice(0, 10)
: null
: place.sourceSnapshot,
},
};
}
private toListItem(
v: PlaceListing,
distance: number | null,
clock: { weekday: number; minutes: number },
): PlaceListItemDto {
return {
id: v.id,
name: v.name,
branchName: v.branchName,
category: v.category,
source: v.source,
address: v.addressRoad,
lat: v.lat,
lng: v.lng,
distanceM:
distance === null || !Number.isFinite(distance)
? null
: Math.round(distance),
imageThumbUrl: v.imageThumbUrl,
imageLicense: v.imageLicense,
hoursOrigin: v.hoursOrigin,
openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null,
petPolicy: v.petPolicy,
petOrigin: v.petOrigin,
setting: v.setting,
verifiedAt: v.verifiedAt,
};
}
}
@@ -0,0 +1,13 @@
import { boundingBox } from './place.sql';
describe('boundingBox', () => {
it('반경을 담는 위도·경도 범위를 만든다(대구 위도에서 경도 폭이 더 넓다)', () => {
const box = boundingBox(35.865, 128.5966, 1500);
const dLat = box.latMax - box.latMin;
const dLng = box.lngMax - box.lngMin;
expect(dLat).toBeCloseTo((2 * 1500) / 111_320, 6);
expect(dLng).toBeGreaterThan(dLat);
expect(box.latMin).toBeLessThan(35.865);
expect(box.lngMax).toBeGreaterThan(128.5966);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다.
*/
/** 위도 1도의 거리(m) */
export const METERS_PER_LAT_DEGREE = 111_320;
/** 두 좌표 사이 거리(m) — 하버사인. 부동소수점 오차로 asin 범위를 넘지 않게 1 로 자른다 */
export function sqlDistanceM(
lat1: string,
lng1: string,
lat2: string,
lng2: string,
): string {
return (
`(2 * 6371000 * asin(LEAST(1, sqrt(` +
`power(sin(radians(${lat2} - ${lat1}) / 2), 2) + ` +
`cos(radians(${lat1})) * cos(radians(${lat2})) * power(sin(radians(${lng2} - ${lng1}) / 2), 2)` +
`))))`
);
}
/**
* 반경 검색 1차 거르기용 사각형(위도·경도 범위). 인덱스(lat, lng)를 타게 한 뒤 정확한 거리로 다시 거른다.
*/
export function boundingBox(
lat: number,
lng: number,
radiusM: number,
): { latMin: number; latMax: number; lngMin: number; lngMax: number } {
const dLat = radiusM / METERS_PER_LAT_DEGREE;
const dLng =
radiusM /
(METERS_PER_LAT_DEGREE * Math.max(Math.cos((lat * Math.PI) / 180), 0.01));
return {
latMin: lat - dLat,
latMax: lat + dLat,
lngMin: lng - dLng,
lngMax: lng + dLng,
};
}
@@ -0,0 +1,38 @@
import { isSameShopName, normalizeShopName } from './shop-name';
// 2026-11-02 대구 적재 데이터에서 관광공사·상가정보가 겹친 실제 사례
describe('isSameShopName', () => {
it('띄어쓰기·괄호·영문 병기가 달라도 같은 가게로 본다', () => {
expect(isSameShopName('커피명가 라핀카', '커피명가라핀카')).toBe(true);
expect(isSameShopName('아눅 앞산', '아눅앞산')).toBe(true);
expect(
isSameShopName(
'낫온리커피어반델리(NOT ONLY COFFEE URBAN DELI)',
'낫온리커피어반델리',
),
).toBe(true);
});
it('한쪽이 다른 쪽을 포함하면(두 글자 이상) 같은 가게로 본다', () => {
expect(isSameShopName('카페 접속', '접속')).toBe(true);
expect(isSameShopName('롤러커피', '롤러커피롤러커피플러스')).toBe(true);
expect(isSameShopName('원조아삼겹 본점', '원조아삼겹')).toBe(true);
});
it('한 글자 상호는 완전히 같을 때만 같은 가게다', () => {
expect(isSameShopName('류', '류')).toBe(true);
expect(isSameShopName('올리브영 대구동성로광장점', '동')).toBe(false);
expect(isSameShopName('정호승 문학관', '문')).toBe(false);
});
it('이름이 전혀 다르면 다른 가게다', () => {
expect(isSameShopName('김진수로스터리카페', '오마모리')).toBe(false);
expect(isSameShopName('', '오마모리')).toBe(false);
});
});
describe('normalizeShopName', () => {
it('소문자 한글·영문·숫자만 남긴다', () => {
expect(normalizeShopName('Cafe 1979 (교동)')).toBe('cafe1979교동');
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 상호 비교 (순수 함수) — 같은 가게를 관광공사·상가정보가 둘 다 가지고 있는지 판단할 때 쓴다.
*/
/** 비교용 정규화: 소문자로, 한글·영문·숫자만 남긴다(공백·괄호·기호 제거) */
export function normalizeShopName(name: string): string {
return name.toLowerCase().replace(/[^0-9a-z가-힣]/g, '');
}
/** 한쪽이 다른 쪽을 포함할 때, 짧은 쪽이 이 글자 수 이상이어야 같은 가게로 본다(한 글자 상호 '동'·'문' 오탐 방지) */
const MIN_CONTAINED_LENGTH = 2;
/**
* 두 상호가 같은 가게로 보이는지.
* - 정규화한 이름이 같으면 같다 ('커피명가 라핀카' = '커피명가라핀카', '류' = '류')
* - 한쪽이 다른 쪽을 포함하고 짧은 쪽이 2글자 이상이면 같다 ('카페 접속' ⊃ '접속', '롤러커피롤러커피플러스' ⊃ '롤러커피')
* 거리·분류 조건은 호출하는 쪽에서 따로 건다(이름만으로는 '이디야커피 국립대구박물관점' ⊃ '국립대구박물관'도 같다고 나온다).
*/
export function isSameShopName(a: string, b: string): boolean {
const x = normalizeShopName(a);
const y = normalizeShopName(b);
if (x === '' || y === '') return false;
if (x === y) return true;
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= MIN_CONTAINED_LENGTH && long.includes(short);
}
@@ -0,0 +1,34 @@
import { BudgetExceededError, CallBudget, sumCalls } from './call-budget';
describe('CallBudget', () => {
it('오늘 이미 쓴 수를 빼고 남은 호출 수를 센다', () => {
const budget = new CallBudget(10, { detailIntro2: 7 });
expect(budget.remaining('detailIntro2')).toBe(3);
expect(budget.remaining('detailCommon2')).toBe(10);
});
it('한도를 넘기면 BudgetExceededError 를 던지고 수를 늘리지 않는다', () => {
const budget = new CallBudget(2);
budget.take('detailCommon2');
budget.take('detailCommon2');
expect(() => budget.take('detailCommon2')).toThrow(BudgetExceededError);
expect(budget.spent()).toEqual({ detailCommon2: 2 });
});
it('이번 실행에서 쓴 수만 따로 돌려준다(이전 실행분 제외)', () => {
const budget = new CallBudget(100, { areaBasedList2: 5 });
budget.take('areaBasedList2');
expect(budget.spent()).toEqual({ areaBasedList2: 1 });
});
});
describe('sumCalls', () => {
it('여러 실행 기록의 오퍼레이션별 호출 수를 합친다', () => {
expect(
sumCalls([
{ detailIntro2: 400, areaBasedList2: 2 },
{ detailIntro2: 300 },
]),
).toEqual({ detailIntro2: 700, areaBasedList2: 2 });
});
});
@@ -0,0 +1,70 @@
/**
* 공공데이터포털 API 하루 호출 한도 관리. 관광공사 개발 계정은 **오퍼레이션마다** 하루 1,000건, 상가정보는 10,000건이다.
* 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다.
*/
/** 한도에 닿았을 때 던진다. 동기화는 이 오류를 받으면 정상 종료한다 */
export class BudgetExceededError extends Error {
constructor(
readonly operation: string,
readonly reason: 'local' | 'server',
) {
super(
reason === 'local'
? `공공데이터 ${operation} 오늘 호출 한도에 닿았습니다`
: `공공데이터 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
);
this.name = 'BudgetExceededError';
}
}
/** 오퍼레이션별 호출 수를 센다. 오늘 이전 실행에서 쓴 수부터 시작한다 */
export class CallBudget {
private readonly used = new Map<string, number>();
private readonly spentThisRun = new Map<string, number>();
constructor(
private readonly limit: number,
usedToday: Record<string, number> = {},
) {
for (const [op, n] of Object.entries(usedToday)) this.used.set(op, n);
}
remaining(operation: string): number {
return Math.max(0, this.limit - (this.used.get(operation) ?? 0));
}
canSpend(operation: string, count = 1): boolean {
return this.remaining(operation) >= count;
}
/** 호출 1회를 쓴다. 남은 한도가 없으면 BudgetExceededError */
take(operation: string): void {
if (!this.canSpend(operation)) {
throw new BudgetExceededError(operation, 'local');
}
this.used.set(operation, (this.used.get(operation) ?? 0) + 1);
this.spentThisRun.set(
operation,
(this.spentThisRun.get(operation) ?? 0) + 1,
);
}
/** 이번 실행에서 쓴 호출 수 — place_sync_run.calls 에 남긴다 */
spent(): Record<string, number> {
return Object.fromEntries(this.spentThisRun);
}
}
/** 여러 실행 기록의 오퍼레이션별 호출 수를 합친다 */
export function sumCalls(
records: readonly Record<string, number>[],
): Record<string, number> {
const total: Record<string, number> = {};
for (const rec of records) {
for (const [op, n] of Object.entries(rec)) {
if (Number.isFinite(n)) total[op] = (total[op] ?? 0) + n;
}
}
return total;
}
@@ -0,0 +1,81 @@
import {
DedupeCandidate,
dropSameVenueDuplicates,
isSameVenueDuplicate,
longestCommonRun,
} from './event-dedupe';
const base: DedupeCandidate = {
sourceId: '100',
name: '문준호 전 - 남겨진 것들을 위한 풍경',
eventPlace: '갤러리소헌&소헌컨템포러리',
startDate: '2026-10-20',
endDate: '2026-11-13',
};
describe('longestCommonRun', () => {
it('연속으로 겹치는 가장 긴 부분의 길이', () => {
expect(longestCommonRun('abcdef', 'zcdez')).toBe(3);
expect(longestCommonRun('', 'abc')).toBe(0);
});
});
describe('isSameVenueDuplicate', () => {
it('같은 장소·기간에 제목이 6글자 이상 겹치면 같은 전시(2026-11-03 실제 사례)', () => {
expect(
isSameVenueDuplicate(base, {
...base,
sourceId: '200',
name: '문준호 《남겨진 것들을 위한 풍경》',
}),
).toBe(true);
});
it('같은 장소·기간이어도 제목이 다르면 다른 전시(여러 전시실이 같은 날 여는 곳)', () => {
expect(
isSameVenueDuplicate(base, {
...base,
sourceId: '200',
name: '유리상자 시리즈展 우덕하 REVERSE FLOW',
}),
).toBe(false);
});
it('기간이 하루라도 다르거나 장소가 다르거나 없으면 합치지 않는다', () => {
const same = {
...base,
sourceId: '200',
name: '문준호 《남겨진 것들을 위한 풍경》',
};
expect(isSameVenueDuplicate(base, { ...same, endDate: '2026-11-14' })).toBe(
false,
);
expect(
isSameVenueDuplicate(base, { ...same, eventPlace: '봉산문화회관' }),
).toBe(false);
expect(
isSameVenueDuplicate(
{ ...base, eventPlace: null },
{ ...same, eventPlace: null },
),
).toBe(false);
});
});
describe('dropSameVenueDuplicates', () => {
it('먼저 등록된 것(id 숫자가 작은 것)만 남긴다', () => {
const later = {
...base,
sourceId: '1000',
name: '문준호 《남겨진 것들을 위한 풍경》',
};
const other = {
...base,
sourceId: '50',
name: '봉산 청년작가공모 틈 - 서승희',
};
const { kept, dropped } = dropSameVenueDuplicates([later, base, other]);
expect(kept.map((r) => r.sourceId)).toEqual(['50', '100']);
expect(dropped.map((r) => r.sourceId)).toEqual(['1000']);
});
});
@@ -0,0 +1,74 @@
import { eventNameKey } from './event-name';
/**
* 같은 출처 안에 제목만 다르게 두 번 등록된 행사 찾기 (순수 함수).
* 2026-11-03 한국문화정보원: "문준호 전 - 남겨진 것들을 위한 풍경" / "문준호 《남겨진 것들을 위한 풍경》"
* (같은 갤러리 · 같은 기간)이 따로 있었다.
*
* 장소·기간만 보면 안 된다 — 대구문화예술회관처럼 여러 전시실에서 같은 날 여러 전시를 여는 곳이 있다.
* 그래서 제목 비교 키가 연속으로 이 글자 수 이상 겹칠 때만 같은 행사로 본다.
*/
export const SAME_EVENT_MIN_COMMON_CHARS = 6;
export interface DedupeCandidate {
sourceId: string;
name: string;
eventPlace: string | null;
startDate: string;
endDate: string;
}
/** 두 문자열에 연속으로 겹치는 가장 긴 부분의 길이 */
export function longestCommonRun(a: string, b: string): number {
let best = 0;
let prev = new Array<number>(b.length + 1).fill(0);
for (let i = 1; i <= a.length; i += 1) {
const cur = new Array<number>(b.length + 1).fill(0);
for (let j = 1; j <= b.length; j += 1) {
if (a[i - 1] === b[j - 1]) {
cur[j] = prev[j - 1] + 1;
if (cur[j] > best) best = cur[j];
}
}
prev = cur;
}
return best;
}
/** 같은 장소(이름 비교 키) · 같은 시작·종료일 · 제목이 6글자 이상 겹치면 같은 행사 */
export function isSameVenueDuplicate(
a: DedupeCandidate,
b: DedupeCandidate,
): boolean {
const placeA = a.eventPlace ? eventNameKey(a.eventPlace) : '';
const placeB = b.eventPlace ? eventNameKey(b.eventPlace) : '';
if (!placeA || placeA !== placeB) return false;
if (a.startDate !== b.startDate || a.endDate !== b.endDate) return false;
return (
longestCommonRun(eventNameKey(a.name), eventNameKey(b.name)) >=
SAME_EVENT_MIN_COMMON_CHARS
);
}
/**
* 같은 행사로 보이는 것 중 먼저 등록된 것(원본 id 숫자가 작은 것)만 남긴다.
* 숫자가 아닌 id 는 문자열 순서로 비교한다.
*/
export function dropSameVenueDuplicates<T extends DedupeCandidate>(
rows: readonly T[],
): { kept: T[]; dropped: T[] } {
const order = (id: string) => (/^\d+$/.test(id) ? Number(id) : Number.NaN);
const sorted = [...rows].sort((x, y) => {
const nx = order(x.sourceId);
const ny = order(y.sourceId);
if (!Number.isNaN(nx) && !Number.isNaN(ny)) return nx - ny;
return x.sourceId.localeCompare(y.sourceId);
});
const kept: T[] = [];
const dropped: T[] = [];
for (const row of sorted) {
if (kept.some((k) => isSameVenueDuplicate(k, row))) dropped.push(row);
else kept.push(row);
}
return { kept, dropped };
}
@@ -0,0 +1,16 @@
/**
* 행사 이름 비교 키 (순수 함수) — 출처가 다른 같은 행사를 찾을 때 쓴다.
* 괄호·따옴표·공백·문장부호를 지우고 소문자로 바꾼다. 퍼지 매칭은 하지 않는다(틀린 병합이 더 해롭다).
* "2026 수성국제비엔날레 《빛의 정원》" → "2026수성국제비엔날레빛의정원"
* "[대구] 굿프렌즈 (앵콜)" → "굿프렌즈"
*/
export function eventNameKey(name: string): string {
return (
name
// 대괄호·소괄호 안은 지역·회차 메모라 버린다
.replace(/\[[^\]]*\]|\([^)]*\)/g, ' ')
.toLowerCase()
// 한글·영문·숫자 외는 모두 지운다(따옴표·꺾쇠·가운뎃점·공백 …)
.replace(/[^0-9a-z가-힣]/g, '')
);
}
@@ -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,380 @@
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';
/**
* 행사를 받을 시·도 — 대구(27)만으로는 90일에 1건뿐이라(2026-11-03 실측: 대구 1 · 경북 7) 근교 경북(47)까지 받는다.
* 화면은 동네 좌표에서 거리순으로 보여 주므로 대구 행사가 먼저 나온다.
*/
export const DEFAULT_EVENT_REGIONS = ['27', '47'];
/** 오늘부터 며칠 안에 열리는 행사를 받을지 */
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 runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<FestivalSyncResult[]> {
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;
}): 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({
source: 'tour',
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();
// 관광공사 행사만 다룬다(KOPIS 공연 등 다른 출처 행사는 각자의 동기화가 관리한다)
const existing = await this.events.find({
where: { source: 'tour' },
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,
source: 'tour',
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('source = :source', { source: 'tour' })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
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', '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: {
source: 'tour',
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);
}
}
@@ -0,0 +1,101 @@
import { BudgetExceededError, CallBudget } from './call-budget';
import { KcisaApiClient, parseKcisaPage } from './kcisa-api.client';
import { FetchLike, PublicDataApiError } from './public-data.client';
const KEY = 'data+go/kr==';
const page = (items: string, totalCount: number) =>
`<?xml version="1.0" encoding="UTF-8"?><response><header><resultCode>00</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header><body><totalCount>${totalCount}</totalCount><PageNo>1</PageNo><numOfrows>100</numOfrows><items>${items}</items></body></response>`;
const ITEM = `<item><seq>314159</seq><title>방짜유기박물관 &amp;lsquo;놋&amp;rsquo; 특별전</title><startDate>20260527</startDate><endDate>20261122</endDate><realmName>전시</realmName><gpsX>128.6914</gpsX><gpsY>35.9897</gpsY></item>`;
function client(responses: string[], budget = new CallBudget(100)) {
const urls: string[] = [];
const fetchImpl: FetchLike = (url) => {
urls.push(url);
const body = responses.shift();
if (body === undefined) throw new Error('예상보다 많이 호출했다');
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(body),
});
};
const api = new KcisaApiClient({
serviceKey: KEY,
budget,
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls, budget };
}
describe('parseKcisaPage', () => {
it('항목을 배열로 바꾸고 XML 엔티티는 한 번만 푼다(HTML 엔티티는 매퍼가 푼다)', () => {
const res = parseKcisaPage(page(ITEM, 1), 'area2');
expect(res.totalCount).toBe(1);
expect(res.items[0]).toMatchObject({
seq: '314159',
title: '방짜유기박물관 &lsquo;놋&rsquo; 특별전',
gpsX: '128.6914',
});
});
it('0건이면 빈 배열', () => {
expect(parseKcisaPage(page('', 0), 'area2')).toEqual({
items: [],
totalCount: 0,
});
});
it('결과 코드가 00 이 아니면 오류', () => {
const xml =
'<response><header><resultCode>03</resultCode><resultMsg>NODATA_ERROR</resultMsg></header></response>';
expect(() => parseKcisaPage(xml, 'detail2')).toThrow(
/detail2: 결과 코드 03 NODATA_ERROR/,
);
});
it('게이트웨이 한도 초과(코드 22)는 한도 오류, 키 오류는 사유를 담은 오류', () => {
const quota =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR</returnAuthMsg><returnReasonCode>22</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseKcisaPage(quota, 'area2')).toThrow(BudgetExceededError);
const badKey =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseKcisaPage(badKey, 'area2')).toThrow(
/SERVICE_KEY_IS_NOT_REGISTERED_ERROR/,
);
});
it('구조가 다르면 오류', () => {
expect(() => parseKcisaPage('<html/>', 'area2')).toThrow(
PublicDataApiError,
);
});
});
describe('KcisaApiClient', () => {
it('serviceKey·지역·기간·쪽 번호를 이 API 의 파라미터 이름으로 보낸다', async () => {
const { api, urls, budget } = client([page(ITEM, 1), page(ITEM, 1)]);
await api.listByArea({
sido: '대구',
from: '20261103',
to: '20270201',
pageNo: 2,
rows: 100,
});
await expect(api.getDetail('314159')).resolves.toMatchObject({
seq: '314159',
});
const list = new URL(urls[0]);
expect(list.origin + list.pathname).toBe(
'https://apis.data.go.kr/B553457/cultureinfo/area2',
);
expect(list.searchParams.get('serviceKey')).toBe(KEY);
expect(list.searchParams.get('sido')).toBe('대구');
expect(list.searchParams.get('PageNo')).toBe('2');
expect(list.searchParams.get('numOfrows')).toBe('100');
expect(new URL(urls[1]).searchParams.get('seq')).toBe('314159');
expect(budget.spent()).toEqual({ area2: 1, detail2: 1 });
});
});
@@ -0,0 +1,132 @@
import { XMLParser } from 'fast-xml-parser';
import { BudgetExceededError } from './call-budget';
import {
PublicDataApiError,
PublicDataClient,
PublicDataClientOptions,
PublicDataItem,
QUOTA_EXCEEDED_RE,
asRecord,
toItems,
} from './public-data.client';
/**
* 한국문화정보원 한눈에보는문화정보조회서비스(공공데이터포털 15138937, XML).
* 키는 관광공사·상가정보와 같은 공공데이터포털 키, 개발 계정 하루 10,000건.
* 2026-11-03 진단: 앞으로 90일 대구 42건(전시 13) · 경북 17건(전시 1), 좌표 채움 대구 38/42.
*/
export const KCISA_API_BASE_URL = 'https://apis.data.go.kr/B553457/cultureinfo';
/** 호출 종류 — 한도·실행 기록의 오퍼레이션 이름 */
export const KCISA_OPERATIONS = {
list: 'area2',
detail: 'detail2',
} as const;
export type KcisaItem = PublicDataItem;
export interface KcisaPage {
items: KcisaItem[];
totalCount: number;
}
const parser = new XMLParser({
ignoreAttributes: true,
// 일련번호·전화·좌표도 문자열 그대로 둔다
parseTagValue: false,
trimValues: true,
isArray: (tagName) => tagName === 'item',
});
/**
* 응답 → 목록. `<response><header><resultCode>00</resultCode>…<body><totalCount/><items><item>…` 구조다.
* 키 오류·한도 초과는 게이트웨이가 `<OpenAPI_ServiceResponse>` 로 답한다(한도 초과는 코드 22).
*/
export function parseKcisaPage(body: string, operation: string): KcisaPage {
if (QUOTA_EXCEEDED_RE.test(body)) {
throw new BudgetExceededError(operation, 'server');
}
let doc: unknown;
try {
doc = parser.parse(body);
} catch {
throw new PublicDataApiError(`${operation}: XML 이 아닌 응답`);
}
const root = asRecord(doc);
const gateway = asRecord(
asRecord(root?.OpenAPI_ServiceResponse)?.cmmMsgHeader,
);
if (gateway) {
const reason =
typeof gateway.returnAuthMsg === 'string' ? gateway.returnAuthMsg : '';
throw new PublicDataApiError(`${operation}: ${reason}`.trim());
}
const response = asRecord(root?.response);
if (!response) {
throw new PublicDataApiError(`${operation}: 알 수 없는 응답 형식`);
}
const header = asRecord(response.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
if (code !== '' && code !== '00' && code !== '0000') {
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
);
}
const resBody = asRecord(response.body);
const items = toItems(asRecord(resBody?.items)?.item);
const totalCount = Number(resBody?.totalCount ?? items.length);
return {
items,
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
};
}
/** 동기화가 쓰는 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface KcisaApi {
/** 지역별 목록 — 기간이 겹치는 행사(from·to 'YYYYMMDD', sido '대구'·'경북') */
listByArea(params: {
sido: string;
from: string;
to: string;
pageNo: number;
rows: number;
}): Promise<KcisaPage>;
/** 상세(seq) — 없으면 null */
getDetail(seq: string): Promise<KcisaItem | null>;
}
export class KcisaApiClient
extends PublicDataClient<KcisaPage>
implements KcisaApi
{
constructor(options: PublicDataClientOptions) {
super(options, KCISA_API_BASE_URL, {});
}
protected parse(body: string, operation: string): KcisaPage {
return parseKcisaPage(body, operation);
}
listByArea(params: {
sido: string;
from: string;
to: string;
pageNo: number;
rows: number;
}): Promise<KcisaPage> {
return this.get(KCISA_OPERATIONS.list, {
sido: params.sido,
from: params.from,
to: params.to,
PageNo: params.pageNo,
numOfrows: params.rows,
});
}
async getDetail(seq: string): Promise<KcisaItem | null> {
const page = await this.get(KCISA_OPERATIONS.detail, { seq });
return page.items[0] ?? null;
}
}
@@ -0,0 +1,108 @@
import { eventNameKey } from './event-name';
import { kcisaCoords, kcisaDetail, kcisaItemToEvent } from './kcisa-mapper';
describe('kcisaItemToEvent', () => {
const item = {
seq: '314159',
title: '2026년 방짜유기박물관 &lsquo;놋&rsquo; 특별전',
startDate: '20260527',
endDate: '20261122',
realmName: '전시',
place: '대구방짜유기박물관',
area: '대구',
sigungu: '동구',
gpsX: '128.6914',
gpsY: '35.9897',
thumbnail: 'https://www.culture.go.kr/upload/a.jpg',
};
it('전시를 기본 컬럼으로 바꾸고 이미지는 넣지 않는다', () => {
expect(kcisaItemToEvent(item)).toEqual({
sourceId: '314159',
name: '2026년 방짜유기박물관 ‘놋’ 특별전',
startDate: '2026-05-27',
endDate: '2026-11-22',
genre: '전시',
eventPlace: '대구방짜유기박물관',
area: '대구',
lat: 35.9897,
lng: 128.6914,
});
});
it('전시가 아니면 건너뛴다(공연은 KOPIS 가 맡는다)', () => {
expect(kcisaItemToEvent({ ...item, realmName: '음악/콘서트' })).toEqual({
skip: 'not_exhibition',
});
});
it('일련번호가 숫자가 아니거나 기간이 틀리면 건너뛴다', () => {
expect(kcisaItemToEvent({ ...item, seq: 'a1' })).toEqual({
skip: 'no_id',
});
expect(kcisaItemToEvent({ ...item, endDate: '20260101' })).toEqual({
skip: 'bad_dates',
});
});
});
describe('kcisaCoords', () => {
it('gpsY 가 위도, gpsX 가 경도다', () => {
expect(kcisaCoords({ gpsX: '128.6914', gpsY: '35.9897' }, '대구')).toEqual({
lat: 35.9897,
lng: 128.6914,
});
});
it('지역 영역 밖·비어 있음·뒤바뀜이면 좌표를 버린다', () => {
const none = { lat: null, lng: null };
expect(kcisaCoords({ gpsX: '129.2123', gpsY: '35.8471' }, '대구')).toEqual(
none,
);
expect(kcisaCoords({ gpsX: '', gpsY: '' }, '대구')).toEqual(none);
expect(kcisaCoords({ gpsX: '35.9897', gpsY: '128.6914' }, null)).toEqual(
none,
);
});
});
describe('kcisaDetail', () => {
it('요금·소개(HTML 정리)·전화·주소·안내 링크를 꺼낸다', () => {
expect(
kcisaDetail({
price: '무료',
contents1: '<p>놋그릇 특별전</p><br>관람 시간 09:00~18:00',
phone: '053-606-6171',
placeAddr: '대구광역시 동구 도장길 29',
url: 'https://artcenter.daegu.go.kr/bangjja',
}),
).toEqual({
feeText: '무료',
overview: '놋그릇 특별전\n\n관람 시간 09:00~18:00',
phone: '053-606-6171',
address: '대구광역시 동구 도장길 29',
homepage: 'https://artcenter.daegu.go.kr/bangjja',
});
});
it('안내 링크가 없으면 장소 링크를 쓰고, 둘 다 없으면 null', () => {
expect(kcisaDetail({ placeUrl: 'www.daeguartmuseum.or.kr' }).homepage).toBe(
'https://www.daeguartmuseum.or.kr',
);
expect(kcisaDetail({}).homepage).toBeNull();
});
});
describe('eventNameKey', () => {
it('괄호 메모·따옴표·공백·문장부호를 지운 비교 키를 만든다', () => {
expect(eventNameKey('2026 수성국제비엔날레 《빛의 정원》')).toBe(
'2026수성국제비엔날레빛의정원',
);
expect(eventNameKey('[대구] 굿프렌즈 (앵콜)')).toBe('굿프렌즈');
expect(eventNameKey('Jazz in Blue')).toBe(eventNameKey('JAZZ IN BLUE'));
});
it('글자가 하나도 남지 않으면 빈 문자열(비교에 쓰지 않는다)', () => {
expect(eventNameKey('[대구]')).toBe('');
});
});
@@ -0,0 +1,100 @@
import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { toIsoDate } from './festival-mapper';
import type { KcisaItem } from './kcisa-api.client';
/**
* 한국문화정보원 한눈에보는문화정보 → festival_event 컬럼 (순수 함수).
* 공연은 KOPIS 와 겹치므로 **전시만** 받는다. 칸 이름은 2026-11-03 진단 기준이다.
*
* 이미지(thumbnail·imgUrl)는 저장하지 않는다 — 문화공공데이터광장 저작권정책상 공공누리 1유형이 아니면
* 사전 협의가 필요한데 아직 확인하지 않았다(docs/research/pre-dev-check/culture_event_sources.md 4장 4번).
*/
/** 받을 분야(realmName) */
export const KCISA_EXHIBITION_REALM = '전시';
/** 일련번호 — 숫자만 받는다 */
const SEQ_RE = /^\d+$/;
export interface KcisaListEvent {
sourceId: string;
name: string;
startDate: string;
endDate: string;
genre: string;
eventPlace: string | null;
/** 목록 area('대구') — 지역 확인용 */
area: string | null;
lat: number | null;
lng: number | null;
}
export type KcisaSkipReason = 'no_id' | 'bad_dates' | 'not_exhibition';
function str(item: KcisaItem, 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;
}
/**
* 좌표 — gpsY 가 위도, gpsX 가 경도다(순서 주의). 주소·지역의 시·도 영역 밖이면 버린다.
*/
export function kcisaCoords(
item: KcisaItem,
regionText: string | null,
): { lat: number | null; lng: number | null } {
const lat = Number(str(item, 'gpsY'));
const lng = Number(str(item, 'gpsX'));
const sido = sidoCodeFromAddress(regionText);
const ok = sido ? isInSido(sido, lat, lng) : isInKorea(lat, lng);
return ok ? { lat, lng } : { lat: null, lng: null };
}
/** 목록 항목 1건 → 기본 컬럼. 전시가 아니거나 쓸 수 없으면 사유를 돌려준다 */
export function kcisaItemToEvent(
item: KcisaItem,
): KcisaListEvent | { skip: KcisaSkipReason } {
const sourceId = str(item, 'seq');
const name = htmlToText(item.title);
if (!sourceId || !SEQ_RE.test(sourceId) || !name) return { skip: 'no_id' };
if (str(item, 'realmName') !== KCISA_EXHIBITION_REALM) {
return { skip: 'not_exhibition' };
}
const startDate = toIsoDate(str(item, 'startDate'));
const endDate = toIsoDate(str(item, 'endDate'));
if (!startDate || !endDate || endDate < startDate) {
return { skip: 'bad_dates' };
}
const area = str(item, 'area');
return {
sourceId,
name,
startDate,
endDate,
genre: KCISA_EXHIBITION_REALM,
eventPlace: htmlToText(item.place),
area,
...kcisaCoords(item, area),
};
}
export interface KcisaDetail {
feeText: string | null;
overview: string | null;
phone: string | null;
address: string | null;
homepage: string | null;
}
/** 상세 → 요금·소개·전화·주소·안내 링크 */
export function kcisaDetail(item: KcisaItem): KcisaDetail {
return {
feeText: htmlToText(item.price),
overview: htmlToText(item.contents1),
phone: htmlToText(item.phone),
address: htmlToText(item.placeAddr),
homepage: extractUrl(item.url) ?? extractUrl(item.placeUrl),
};
}
@@ -0,0 +1,431 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { sidoCodeFromAddress } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { dropSameVenueDuplicates } from './event-dedupe';
import { eventNameKey } from './event-name';
import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync';
import { KCISA_OPERATIONS } from './kcisa-api.client';
import type { KcisaApi, KcisaPage } from './kcisa-api.client';
import { KcisaSkipReason, kcisaDetail, kcisaItemToEvent } from './kcisa-mapper';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
const SOURCE = 'kcisa';
/** 시·도 코드 → 이 API 의 sido 값 */
export const KCISA_SIDO_NAMES: Record<string, string> = {
'27': '대구',
'47': '경북',
};
const PAGE_SIZE = 100;
const MAX_PAGES = 20;
const UPSERT_CHUNK = 200;
const MAX_CONSECUTIVE_FAILURES = 10;
/** 원본에 수정일이 없어 상세를 이 주기로 다시 받는다 */
const DETAIL_REFRESH_DAYS = 7;
const DAY_MS = 86_400_000;
/** 목록 upsert 때 덮어쓰는 컬럼(상세 컬럼은 상세를 받을 때만 쓴다) */
const LIST_OVERWRITE_PROPS = [
'kind',
'name',
'startDate',
'endDate',
'genre',
'eventPlace',
'lat',
'lng',
'ldongRegnCd',
'status',
'lastSeenAt',
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason =
| KcisaSkipReason
| 'other_region'
| 'duplicate_other_source'
| 'duplicate_same_source';
export interface KcisaSyncResult {
regionCode: string;
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[];
}
/** 목록에서 만든 행 — 중복 판단에 이름·장소·기간이 필요하다 */
type KcisaRow = Partial<FestivalEvent> & {
sourceId: string;
name: string;
eventPlace: string | null;
startDate: string;
endDate: string;
};
const toYmd = (iso: string) => iso.replace(/-/g, '');
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
/**
* 한국문화정보원 전시 동기화(매일). 오늘부터 N일 안의 시·도 행사 목록에서 **전시만** festival_event(source='kcisa')에 넣는다.
* 1. 목록(area2) — 전시가 아니거나, 관광공사·KOPIS 에 같은 이름·겹치는 기간의 행사가 이미 있으면 건너뛴다.
* 목록에서 없어진 전시는 숨긴다(원본 장애 의심이면 건너뜀)
* 2. 상세(detail2) — 새 전시와 7일 지난 전시만(원본에 수정일이 없다). 요금·소개·전화·주소·안내 링크
* 좌표는 목록에 있다. 이미지는 권리 확인 전이라 저장하지 않는다(kcisa-mapper.ts).
*/
@Injectable()
export class KcisaSync {
private readonly logger = new Logger(KcisaSync.name);
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<KcisaSyncResult[]> {
const unknown = options.regionCodes.filter((c) => !KCISA_SIDO_NAMES[c]);
if (unknown.length > 0) {
throw new Error(
`한눈에보는문화정보 지역 이름을 모르는 시·도 코드: ${unknown.join(', ')} (kcisa.sync.ts KCISA_SIDO_NAMES 에 추가)`,
);
}
const results: KcisaSyncResult[] = [];
for (const regionCode of options.regionCodes) {
const result = await this.run({ regionCode, days: options.days });
results.push(result);
if (result.stoppedByBudget) break;
}
return results;
}
async run(options: {
regionCode: string;
days?: number;
}): Promise<KcisaSyncResult> {
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.kcisaDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createKcisa(budget);
const run = await this.recorder.start('kcisa', regionCode);
const result: KcisaSyncResult = {
regionCode,
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.detailPendingQuery(
regionCode,
window.from,
).getCount();
} catch (error) {
result.errors.unshift(errorText(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;
}
/** 1. 목록 → 전시만 upsert, 목록에서 없어진 전시 숨김 */
private async syncList(
api: KcisaApi,
regionCode: string,
window: { from: string; to: string },
result: KcisaSyncResult,
): Promise<void> {
const runStart = new Date();
const existing = await this.events.find({
where: { source: SOURCE },
select: {
id: true,
sourceId: true,
status: true,
ldongRegnCd: true,
startDate: true,
endDate: true,
},
});
const bySourceId = new Map(existing.map((e) => [e.sourceId, e]));
const isDuplicate = await this.otherSourceMatcher(regionCode, window);
const listed = new Map<string, KcisaRow>();
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let res: KcisaPage;
try {
res = await api.listByArea({
sido: KCISA_SIDO_NAMES[regionCode],
from: toYmd(window.from),
to: toYmd(window.to),
pageNo: page,
rows: PAGE_SIZE,
});
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
throw error;
}
result.totalCount = res.totalCount;
result.fetched += res.items.length;
for (const item of res.items) {
const mapped = kcisaItemToEvent(item);
if ('skip' in mapped) {
this.skip(result, mapped.skip);
continue;
}
const { area, ...columns } = mapped;
const sido = sidoCodeFromAddress(area);
if (sido !== null && sido !== regionCode) {
this.skip(result, 'other_region');
continue;
}
if (isDuplicate(columns.name, columns.startDate, columns.endDate)) {
this.skip(result, 'duplicate_other_source');
continue;
}
listed.set(columns.sourceId, {
...columns,
source: SOURCE,
kind: 'exhibition',
ldongRegnCd: regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
});
}
if (res.items.length < PAGE_SIZE || result.fetched >= result.totalCount) {
complete = true;
break;
}
}
// 같은 전시가 제목만 다르게 두 번 등록된 경우 먼저 등록된 것만 남긴다(event-dedupe.ts)
const { kept, dropped } = dropSameVenueDuplicates([...listed.values()]);
for (let i = 0; i < dropped.length; i += 1) {
this.skip(result, 'duplicate_same_source');
}
const rows = new Map(kept.map((r) => [r.sourceId, r]));
for (const id of rows.keys()) {
if (bySourceId.has(id)) result.updated += 1;
else result.inserted += 1;
}
await this.upsert(kept);
if (!complete) return;
// 받은 기간과 겹치는데 이번에 넣지 않은 전시는 숨긴다(취소·다른 출처와 중복으로 바뀜)
const inWindowBefore = existing.filter(
(e) =>
e.status === 'active' &&
e.ldongRegnCd === regionCode &&
e.endDate >= window.from &&
e.startDate <= window.to,
);
const missing = inWindowBefore.filter((e) => !rows.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('source = :source', { source: SOURCE })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
result.hidden = hidden.affected ?? 0;
}
/**
* 관광공사·KOPIS 에 이미 있는 행사와 같은지 — 이름 비교 키가 같고 기간이 겹치면 같은 행사로 본다.
* (예: 비엔날레가 관광공사 '행사'와 문화정보원 '전시'에 함께 있다)
*/
private async otherSourceMatcher(
regionCode: string,
window: { from: string; to: string },
): Promise<(name: string, start: string, end: string) => boolean> {
const others = await this.events
.createQueryBuilder('e')
.select(['e.name', 'e.startDate', 'e.endDate'])
.where({ source: Not(SOURCE) })
.andWhere('e.status = :active', { active: 'active' })
.andWhere('e.ldong_regn_cd = :region', { region: regionCode })
.andWhere('e.end_date >= :from AND e.start_date <= :to', window)
.getMany();
const byKey = new Map<string, Array<{ start: string; end: string }>>();
for (const o of others) {
const key = eventNameKey(o.name);
if (!key) continue;
byKey.set(key, [
...(byKey.get(key) ?? []),
{ start: o.startDate, end: o.endDate },
]);
}
return (name, start, end) =>
(byKey.get(eventNameKey(name)) ?? []).some(
(o) => o.end >= start && o.start <= end,
);
}
private skip(result: KcisaSyncResult, reason: SkipReason): void {
result.skipped[reason] = (result.skipped[reason] ?? 0) + 1;
}
private async upsert(rows: Partial<FestivalEvent>[]): Promise<void> {
const overwrite = LIST_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', 'source_id'])
.execute();
}
}
/** 상세를 받아야 하는 전시 — 새 전시 · 7일 지난 전시 */
private detailPendingQuery(regionCode: string, today: string) {
return this.events
.createQueryBuilder('e')
.select(['e.id', 'e.sourceId'])
.where('e.source = :source', { source: SOURCE })
.andWhere('e.ldong_regn_cd = :region', { region: regionCode })
.andWhere('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :today', { today })
.andWhere('(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale)', {
stale: new Date(Date.now() - DETAIL_REFRESH_DAYS * DAY_MS),
})
.orderBy('e.start_date', 'ASC')
.addOrderBy('e.id', 'ASC');
}
/** 2. 상세 → 요금·소개·전화·주소·안내 링크 */
private async syncDetails(
api: KcisaApi,
budget: CallBudget,
regionCode: string,
today: string,
result: KcisaSyncResult,
): Promise<void> {
const pending = await this.detailPendingQuery(regionCode, today).getMany();
let consecutiveFailures = 0;
for (const event of pending) {
if (!budget.canSpend(KCISA_OPERATIONS.detail)) {
result.stoppedByBudget = true;
break;
}
try {
const item = await api.getDetail(event.sourceId);
if (!item) throw new Error('상세가 비어 있음');
await this.events.update(event.id, {
...kcisaDetail(item),
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}: ${errorText(error)}`);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
private report(r: KcisaSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`문화정보원 전시 ${r.regionCode} (${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);
}
}
@@ -0,0 +1,135 @@
import { CallBudget } from './call-budget';
import { KopisApiClient, parseKopisBody } from './kopis-api.client';
import { FetchLike, PublicDataApiError } from './public-data.client';
const KEY = 'kopis+key/123';
const LIST_XML = `<?xml version="1.0" encoding="UTF-8"?>
<dbs>
<db><mt20id>PF100001</mt20id><prfnm>대구시립교향악단 정기연주회 &amp; 협연</prfnm><prfpdfrom>2026.12.04</prfpdfrom></db>
<db><mt20id>PF100002</mt20id><prfnm>장화신은 고양이</prfnm></db>
</dbs>`;
const DETAIL_XML = `<dbs><db>
<mt20id>PF100001</mt20id><mt10id>FC001193</mt10id><telno>053-250-1400</telno>
<styurls><styurl>https://kopis.or.kr/a.jpg</styurl></styurls>
<relates><relate><relatenm>티켓링크</relatenm><relateurl>https://www.ticketlink.co.kr/1</relateurl></relate></relates>
</db></dbs>`;
function client(responses: string[], budget = new CallBudget(100)) {
const urls: string[] = [];
const fetchImpl: FetchLike = (url) => {
urls.push(url);
const body = responses.shift();
if (body === undefined) throw new Error('예상보다 많이 호출했다');
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(body),
});
};
const api = new KopisApiClient({
serviceKey: KEY,
budget,
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls, budget };
}
describe('parseKopisBody', () => {
it('<db> 여러 건을 항목 배열로 바꾸고 엔티티를 되돌린다', () => {
const items = parseKopisBody(LIST_XML, 'pblprfr');
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
mt20id: 'PF100001',
prfnm: '대구시립교향악단 정기연주회 & 협연',
prfpdfrom: '2026.12.04',
});
});
it('1건이어도 배열이고, 숫자처럼 보이는 값도 문자열로 둔다', () => {
const [db] = parseKopisBody(DETAIL_XML, 'pblprfr-detail');
expect(db.telno).toBe('053-250-1400');
expect(db.styurls).toEqual({ styurl: ['https://kopis.or.kr/a.jpg'] });
expect(db.relates).toEqual({
relate: [
{ relatenm: '티켓링크', relateurl: 'https://www.ticketlink.co.kr/1' },
],
});
});
it('0건(<dbs/>)이면 빈 배열', () => {
expect(parseKopisBody('<dbs/>', 'pblprfr')).toEqual([]);
expect(parseKopisBody('<dbs></dbs>', 'pblprfr')).toEqual([]);
});
it('returncode·errmsg 가 오면 사유를 담은 오류로 본다', () => {
const xml =
'<dbs><db><returncode>02</returncode><errmsg>SERVICE KEY IS NOT REGISTERED ERROR.</errmsg></db></dbs>';
expect(() => parseKopisBody(xml, 'pblprfr')).toThrow(
/pblprfr: 결과 코드 02 SERVICE KEY IS NOT REGISTERED ERROR\./,
);
});
it('XML 이 아니거나 구조가 다르면 오류', () => {
expect(() =>
parseKopisBody('<html><body>점검 중</body></html>', 'x'),
).toThrow(PublicDataApiError);
expect(() => parseKopisBody('', 'x')).toThrow(PublicDataApiError);
});
});
describe('KopisApiClient', () => {
it('키는 service 파라미터로 싣고 목록 조건을 그대로 넘긴다', async () => {
const { api, urls, budget } = client([LIST_XML]);
const items = await api.listPerformances({
stdate: '20261103',
eddate: '20270201',
signgucode: '27',
cpage: 1,
rows: 100,
});
expect(items).toHaveLength(2);
const url = new URL(urls[0]);
expect(url.origin + url.pathname).toBe(
'https://kopis.or.kr/openApi/restful/pblprfr',
);
expect(url.searchParams.get('service')).toBe(KEY);
expect(url.searchParams.has('serviceKey')).toBe(false);
expect(url.searchParams.get('signgucode')).toBe('27');
expect(url.searchParams.has('afterdate')).toBe(false);
expect(budget.spent()).toEqual({ pblprfr: 1 });
});
it('상세는 경로에 id 를 넣고 한도는 오퍼레이션 이름으로 센다', async () => {
const { api, urls, budget } = client([DETAIL_XML, '<dbs/>']);
await expect(api.getPerformance('PF100001')).resolves.toMatchObject({
mt10id: 'FC001193',
});
await expect(api.getVenue('FC001193')).resolves.toBeNull();
expect(new URL(urls[0]).pathname).toMatch(/\/pblprfr\/PF100001$/);
expect(new URL(urls[1]).pathname).toMatch(/\/prfplc\/FC001193$/);
expect(budget.spent()).toEqual({
'pblprfr-detail': 1,
'prfplc-detail': 1,
});
});
it('영문·숫자가 아닌 id 는 부르지 않는다(경로 조작 방지)', async () => {
const { api, urls } = client([]);
await expect(api.getPerformance('../prfplc/FC1')).rejects.toThrow(
PublicDataApiError,
);
expect(urls).toHaveLength(0);
});
it('오류 메시지에 키가 섞여도 가린다', async () => {
const { api } = client([
`<dbs><db><returncode>02</returncode><errmsg>bad key ${KEY}</errmsg></db></dbs>`,
]);
const error = await api.getVenue('FC1').catch((e: Error) => e);
expect(error).toBeInstanceOf(PublicDataApiError);
expect((error as Error).message).not.toContain(KEY);
});
});
@@ -0,0 +1,135 @@
import { XMLParser } from 'fast-xml-parser';
import {
PublicDataApiError,
PublicDataClient,
PublicDataClientOptions,
PublicDataItem,
asRecord,
toItems,
} from './public-data.client';
/**
* KOPIS 공연예술통합전산망 Open API (XML).
* 2026-11-03 진단: https 동작, 90일 기간을 한 번에 조회 가능, 초당 10회에서 차단되므로 0.6초 간격으로 부른다.
* 출처 표기 의무: "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)".
*/
export const KOPIS_API_BASE_URL = 'https://kopis.or.kr/openApi/restful';
const KOPIS_CALL_INTERVAL_MS = 600;
/** 호출 종류 — 한도·실행 기록의 오퍼레이션 이름 */
export const KOPIS_OPERATIONS = {
list: 'pblprfr',
detail: 'pblprfr-detail',
venue: 'prfplc-detail',
} as const;
export type KopisItem = PublicDataItem;
/** 같은 이름이 여러 번 나와도 하나만 나와도 배열로 받는 태그 */
const ARRAY_TAGS = new Set(['db', 'styurl', 'relate']);
const parser = new XMLParser({
ignoreAttributes: true,
// 숫자처럼 보이는 값(공연 id·전화번호·좌표)도 문자열 그대로 둔다
parseTagValue: false,
trimValues: true,
isArray: (tagName) => ARRAY_TAGS.has(tagName),
});
/**
* KOPIS 응답 → `<db>` 항목들. 목록·상세·공연시설 모두 `<dbs><db>…</db></dbs>` 구조다.
* 키 오류 등은 `<db><returncode>…</returncode><errmsg>…</errmsg></db>` 로 온다. 0건이면 `<dbs/>`.
*/
export function parseKopisBody(body: string, operation: string): KopisItem[] {
let doc: unknown;
try {
doc = parser.parse(body);
} catch {
throw new PublicDataApiError(`${operation}: XML 이 아닌 응답`);
}
const root = asRecord(doc);
if (!root || !('dbs' in root)) {
throw new PublicDataApiError(`${operation}: 알 수 없는 응답 형식`);
}
const items = toItems(asRecord(root.dbs)?.db);
const first = items[0];
if (first && ('returncode' in first || 'errmsg' in first)) {
const code = typeof first.returncode === 'string' ? first.returncode : '';
const msg = typeof first.errmsg === 'string' ? first.errmsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
);
}
return items;
}
/** 동기화가 쓰는 KOPIS 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface KopisApi {
/** 공연목록 한 쪽 — 기간이 겹치는 공연(stdate·eddate 'YYYYMMDD') */
listPerformances(params: {
stdate: string;
eddate: string;
signgucode: string;
cpage: number;
rows: number;
afterdate?: string;
}): Promise<KopisItem[]>;
/** 공연상세(mt20id) — 없으면 null */
getPerformance(mt20id: string): Promise<KopisItem | null>;
/** 공연시설상세(mt10id) — 없으면 null */
getVenue(mt10id: string): Promise<KopisItem | null>;
}
/** 경로에 넣는 id — 영문·숫자만 허용한다(경로 조작 방지) */
function safeId(id: string, operation: string): string {
if (!/^[A-Za-z0-9]+$/.test(id)) {
throw new PublicDataApiError(`${operation}: 형식이 잘못된 id`);
}
return id;
}
export class KopisApiClient
extends PublicDataClient<KopisItem[]>
implements KopisApi
{
constructor(options: PublicDataClientOptions) {
super(
options,
KOPIS_API_BASE_URL,
{},
{ keyParam: 'service', intervalMs: KOPIS_CALL_INTERVAL_MS },
);
}
protected parse(body: string, operation: string): KopisItem[] {
return parseKopisBody(body, operation);
}
listPerformances(params: {
stdate: string;
eddate: string;
signgucode: string;
cpage: number;
rows: number;
afterdate?: string;
}): Promise<KopisItem[]> {
const { afterdate, ...rest } = params;
return this.get(KOPIS_OPERATIONS.list, {
...rest,
...(afterdate ? { afterdate } : {}),
});
}
async getPerformance(mt20id: string): Promise<KopisItem | null> {
const op = KOPIS_OPERATIONS.detail;
const items = await this.get(op, {}, `pblprfr/${safeId(mt20id, op)}`);
return items[0] ?? null;
}
async getVenue(mt10id: string): Promise<KopisItem | null> {
const op = KOPIS_OPERATIONS.venue;
const items = await this.get(op, {}, `prfplc/${safeId(mt10id, op)}`);
return items[0] ?? null;
}
}
@@ -0,0 +1,240 @@
import {
kopisDate,
kopisDetail,
kopisImageUrl,
kopisListItemToEvent,
kopisTimestamp,
kopisVenue,
stripRegionTag,
} from './kopis-mapper';
describe('kopisDate', () => {
it("'YYYY.MM.DD' 를 ISO 날짜로 바꾼다", () => {
expect(kopisDate('2026.12.04')).toBe('2026-12-04');
});
it('형식이 다르거나 없는 날짜면 null', () => {
expect(kopisDate('20261204')).toBeNull();
expect(kopisDate('2026.02.30')).toBeNull();
expect(kopisDate(null)).toBeNull();
});
});
describe('kopisTimestamp', () => {
it('한국 시간 수정일(마이크로초 포함)을 UTC 로 바꾼다', () => {
expect(kopisTimestamp('2026-11-03 15:00:03.094823')?.toISOString()).toBe(
'2026-11-03T06:00:03.000Z',
);
});
it('형식이 다르면 null', () => {
expect(kopisTimestamp('2026.11.03')).toBeNull();
});
});
describe('kopisImageUrl', () => {
it('KOPIS http 주소는 https://kopis.or.kr 로 바꾼다', () => {
expect(
kopisImageUrl('http://www.kopis.or.kr/upload/pfmPoster/PF_1.gif'),
).toBe('https://kopis.or.kr/upload/pfmPoster/PF_1.gif');
expect(kopisImageUrl('/upload/pfmPoster/PF_2.jpg')).toBe(
'https://kopis.or.kr/upload/pfmPoster/PF_2.jpg',
);
});
it('다른 곳의 https 는 그대로, http 는 버린다', () => {
expect(kopisImageUrl('https://cdn.example.org/a.jpg')).toBe(
'https://cdn.example.org/a.jpg',
);
expect(kopisImageUrl('http://cdn.example.org/a.jpg')).toBeNull();
expect(kopisImageUrl(null)).toBeNull();
});
});
describe('kopisListItemToEvent', () => {
const item = {
mt20id: 'PF281234',
prfnm: '제531회 대구시립교향악단 정기연주회',
prfpdfrom: '2026.12.04',
prfpdto: '2026.12.04',
fcltynm: '대구콘서트하우스',
poster: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
area: '대구광역시',
genrenm: '서양음악(클래식)',
prfstate: '공연예정',
};
it('목록 항목을 기본 컬럼으로 바꾼다', () => {
expect(kopisListItemToEvent(item)).toEqual({
sourceId: 'PF281234',
name: '제531회 대구시립교향악단 정기연주회',
startDate: '2026-12-04',
endDate: '2026-12-04',
genre: '서양음악(클래식)',
eventPlace: '대구콘서트하우스',
area: '대구광역시',
imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
imageThumbUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
});
});
it('id·이름이 없거나 id 형식이 이상하면 건너뛴다', () => {
expect(kopisListItemToEvent({ ...item, mt20id: '' })).toEqual({
skip: 'no_id',
});
expect(kopisListItemToEvent({ ...item, mt20id: 'PF/1' })).toEqual({
skip: 'no_id',
});
});
it('기간이 틀렸거나 끝난 공연은 건너뛴다', () => {
expect(kopisListItemToEvent({ ...item, prfpdto: '2026.12.01' })).toEqual({
skip: 'bad_dates',
});
expect(kopisListItemToEvent({ ...item, prfstate: '공연완료' })).toEqual({
skip: 'ended',
});
});
});
describe('kopisDetail', () => {
const db = {
mt20id: 'PF281234',
mt10id: 'FC001193',
prfnm: '제531회 대구시립교향악단 정기연주회',
prfpdfrom: '2026.12.04',
prfpdto: '2026.12.04',
fcltynm: '대구콘서트하우스 (그랜드홀 (대공연장))',
prfcast: '백진현, 김민지',
prfruntime: '1시간 40분',
prfage: '만 7세 이상',
pcseguidance: 'R석 30,000원, S석 16,000원',
dtguidance: '금요일(19:30)',
sty: '<p>송년 음악회</p>',
genrenm: '서양음악(클래식)',
poster: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
updatedate: '2026-11-03 15:00:03.094823',
relates: {
relate: [
{ relatenm: '전화 예매', relateurl: '053-250-1400' },
{ relatenm: '티켓링크', relateurl: 'https://www.ticketlink.co.kr/1' },
],
},
};
it('요금·공연 시간·예매처·시설 id 를 꺼내고 소개에 출연·관람 정보를 붙인다', () => {
expect(kopisDetail(db)).toEqual({
name: '제531회 대구시립교향악단 정기연주회',
startDate: '2026-12-04',
endDate: '2026-12-04',
genre: '서양음악(클래식)',
eventPlace: '대구콘서트하우스 (그랜드홀 (대공연장))',
feeText: 'R석 30,000원, S석 16,000원',
playtimeText: '금요일(19:30)',
overview:
'송년 음악회\n출연: 백진현, 김민지\n관람 시간: 1시간 40분\n관람 연령: 만 7세 이상',
bookingUrl: 'https://www.ticketlink.co.kr/1',
imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif',
sourceModifiedAt: new Date('2026-11-03T06:00:03.000Z'),
venueSourceId: 'FC001193',
});
});
it('비어 있는 칸은 null 이고, 기간이 틀리면 기간을 쓰지 않는다', () => {
const d = kopisDetail({
mt20id: 'PF1',
prfpdfrom: '2026.12.04',
prfpdto: '2026.12.01',
relates: '',
});
expect(d).toMatchObject({
startDate: null,
endDate: null,
overview: null,
bookingUrl: null,
venueSourceId: null,
});
});
});
describe('kopisVenue', () => {
it('주소의 시·도 안 좌표는 그대로 쓴다', () => {
expect(
kopisVenue(
{
fcltynm: '대구콘서트하우스',
adres: '대구광역시 중구 태평로 141 (태평로2가)',
la: '35.876047100000000',
lo: '128.59384449999993',
telno: '053-250-1400',
relateurl: 'http://www.daeguconcerthouse.or.kr',
},
'대구콘서트하우스 (그랜드홀)',
),
).toEqual({
name: '대구콘서트하우스',
address: '대구광역시 중구 태평로 141 (태평로2가)',
lat: 35.8760471,
lng: 128.59384449999993,
phone: '053-250-1400',
homepage: 'http://www.daeguconcerthouse.or.kr',
coordsRejected: false,
});
});
it('대구 주소에 경주 좌표면 좌표를 비운다(2026-11-03 실제 사례)', () => {
const v = kopisVenue(
{
fcltynm: '프로토타운 북성로 본부',
adres: '대구광역시 중구 북성로 96 (북성로1가)',
la: '35.847189000000000',
lo: '129.21230600000000',
},
'프로토타운',
);
expect(v).toMatchObject({ lat: null, lng: null, coordsRejected: true });
});
it('좌표가 없으면 비우되 거부로 세지 않고, 이름이 없으면 대체 이름을 쓴다', () => {
expect(
kopisVenue({ adres: '대구광역시 남구', la: '', lo: '' }, '꿈꾸는씨어터'),
).toMatchObject({
name: '꿈꾸는씨어터',
lat: null,
lng: null,
coordsRejected: false,
});
});
});
describe('stripRegionTag', () => {
it('제목 끝의 지역 꼬리표를 뗀다(2026-11-03 실제 제목)', () => {
expect(stripRegionTag('굿프렌즈 [대구]')).toBe('굿프렌즈');
expect(
stripRegionTag('데뷔 60주년 기념공연, 남진 전국투어 콘서트 [대구 북구]'),
).toBe('데뷔 60주년 기념공연, 남진 전국투어 콘서트');
expect(
stripRegionTag('V.O.S 전국투어 콘서트: 제2회 동창회 [대구 경산]'),
).toBe('V.O.S 전국투어 콘서트: 제2회 동창회');
});
it('꼬리표 안팎의 괄호 메모(앵콜·회차)는 남긴다', () => {
expect(stripRegionTag('무명전설 전국투어 콘서트 [대구 (앵콜) ]')).toBe(
'무명전설 전국투어 콘서트 (앵콜)',
);
expect(stripRegionTag('Jazz in Blue 민경인 트리오 [안동] (11월)')).toBe(
'Jazz in Blue 민경인 트리오 (11월)',
);
expect(stripRegionTag('온 더 레드카펫 [대구] (11.10)')).toBe(
'온 더 레드카펫 (11.10)',
);
});
it('지역이 아닌 꼬리표·가운데 꼬리표·꼬리표뿐인 제목은 그대로 둔다', () => {
expect(stripRegionTag('라라라온 [롴돰 로컬다움]')).toBe(
'라라라온 [롴돰 로컬다움]',
);
expect(stripRegionTag('[대구] 송년 음악회')).toBe('[대구] 송년 음악회');
expect(stripRegionTag('[대구]')).toBe('[대구]');
});
});
@@ -0,0 +1,244 @@
import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { parseKstTimestamp } from '@/shared/utils/kst';
import { toIsoDate } from './festival-mapper';
import type { KopisItem } from './kopis-api.client';
import { asRecord, toItems } from './public-data.client';
/**
* KOPIS 공연목록·공연상세·공연시설상세 → festival_event / event_venue 컬럼 (순수 함수).
* 칸 이름은 2026-11-03 진단(tools/data-check/culture_api_check.py) 기준이다.
*/
const KOPIS_HOST_RE = /^https?:\/\/(www\.)?kopis\.or\.kr\//i;
const KOPIS_HTTPS_ORIGIN = 'https://kopis.or.kr/';
/** KOPIS id(공연 PF…, 시설 FC…) — 상세 주소 경로에 들어가므로 영문·숫자만 받는다 */
const KOPIS_ID_RE = /^[A-Za-z0-9]+$/;
/** 끝난 공연 — 기간이 겹쳐 목록에 와도 넣지 않는다 */
const ENDED_STATE = '공연완료';
/**
* 순회 공연 제목 끝의 지역 꼬리표("굿프렌즈 [대구]")에 쓰이는 이름 — 시·도와 대구·경북 시군구.
* 2026-11-03 대구·경북 공연 244건 중 203건에 붙어 있었다("[대구]" 141 · "[안동]" 15 · "[대구 북구]" …).
*/
const REGION_TAG_WORDS = new Set([
// 시·도
...['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종'],
...['경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'],
// 대구 구·군
...[
'중구',
'동구',
'서구',
'남구',
'북구',
'수성',
'수성구',
'달서',
'달서구',
],
...['달성', '달성군', '군위', '군위군'],
// 경북 시·군
...['포항', '경주', '김천', '안동', '구미', '영주', '영천', '상주', '문경'],
...['경산', '의성', '청송', '영양', '영덕', '청도', '고령', '성주', '칠곡'],
...['예천', '봉화', '울진', '울릉'],
]);
/** 제목 끝의 [꼬리표] 와 그 뒤에 붙는 회차 표시("(11월)", "(11.10)") */
const TRAILING_TAG_RE = /\s*\[([^[\]]*)\](\s*\([^()]*\))?\s*$/;
export interface KopisListEvent {
sourceId: string;
name: string;
startDate: string;
endDate: string;
genre: string | null;
eventPlace: string | null;
/** 목록 area('대구광역시') — 지역 확인용 */
area: string | null;
imageUrl: string | null;
imageThumbUrl: string | null;
}
export type KopisSkipReason = 'no_id' | 'bad_dates' | 'ended';
function str(item: KopisItem, 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;
}
/** 'YYYY.MM.DD' → 'YYYY-MM-DD'. 형식이 틀리거나 없는 날짜면 null */
export function kopisDate(value: string | null): string | null {
const m = value ? /^(\d{4})\.(\d{2})\.(\d{2})$/.exec(value) : null;
return m ? toIsoDate(`${m[1]}${m[2]}${m[3]}`) : null;
}
/** 상세 updatedate('2026-11-03 15:00:03.094823', 한국 시간) → Date */
export function kopisTimestamp(value: string | null): Date | null {
const m = value
? /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(value)
: null;
return m ? parseKstTimestamp(m.slice(1).join('')) : null;
}
/**
* 포스터 주소 — KOPIS 는 http://www.kopis.or.kr/upload/… 로 준다.
* 앱은 http 이미지를 막으므로 https://kopis.or.kr/ 로 바꾼다(www 는 https 에서 이 주소로 넘긴다).
*/
export function kopisImageUrl(value: string | null): string | null {
if (!value) return null;
if (KOPIS_HOST_RE.test(value)) {
return value.replace(KOPIS_HOST_RE, KOPIS_HTTPS_ORIGIN);
}
if (value.startsWith('/upload/'))
return `${KOPIS_HTTPS_ORIGIN}${value.slice(1)}`;
return /^https:\/\//i.test(value) ? value : null;
}
/**
* 제목 끝의 지역 꼬리표를 뗀다. 꼬리표 안이 지역 이름과 괄호 메모로만 되어 있을 때만 떼고, 메모는 남긴다.
* "굿프렌즈 [대구]" → "굿프렌즈" · "무명전설 콘서트 [대구 (앵콜) ]" → "무명전설 콘서트 (앵콜)"
* "… 트리오 [안동] (11월)" → "… 트리오 (11월)" · "라라라온 [롴돰 로컬다움]" → 그대로
*/
export function stripRegionTag(name: string): string {
const m = TRAILING_TAG_RE.exec(name);
if (!m || m.index === 0) return name;
const notes = m[1].match(/\([^()]*\)/g) ?? [];
const words = m[1]
.replace(/\([^()]*\)/g, ' ')
.split(/\s+/)
.filter(Boolean);
if (words.length === 0 || !words.every((w) => REGION_TAG_WORDS.has(w))) {
return name;
}
const suffix = [...notes, m[2]?.trim()]
.filter((v): v is string => !!v)
.map((v) => v.replace(/\(\s*/, '(').replace(/\s*\)/, ')'))
.join(' ');
return [name.slice(0, m.index), suffix].filter(Boolean).join(' ').trim();
}
/** 목록 항목 1건 → 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function kopisListItemToEvent(
item: KopisItem,
): KopisListEvent | { skip: KopisSkipReason } {
const sourceId = str(item, 'mt20id');
const title = htmlToText(item.prfnm);
const name = title ? stripRegionTag(title) : null;
if (!sourceId || !KOPIS_ID_RE.test(sourceId) || !name) {
return { skip: 'no_id' };
}
const startDate = kopisDate(str(item, 'prfpdfrom'));
const endDate = kopisDate(str(item, 'prfpdto'));
if (!startDate || !endDate || endDate < startDate) {
return { skip: 'bad_dates' };
}
if (str(item, 'prfstate') === ENDED_STATE) return { skip: 'ended' };
const poster = kopisImageUrl(str(item, 'poster'));
return {
sourceId,
name,
startDate,
endDate,
genre: str(item, 'genrenm'),
eventPlace: htmlToText(item.fcltynm),
area: str(item, 'area'),
imageUrl: poster,
imageThumbUrl: poster,
};
}
export interface KopisDetail {
name: string | null;
startDate: string | null;
endDate: string | null;
genre: string | null;
eventPlace: string | null;
feeText: string | null;
playtimeText: string | null;
overview: string | null;
bookingUrl: string | null;
imageUrl: string | null;
sourceModifiedAt: Date | null;
/** 공연시설 id(mt10id) — 좌표는 시설 상세에만 있다 */
venueSourceId: string | null;
}
/** '출연: …' 처럼 이름을 붙인 줄. 값이 없으면 null */
function labeled(label: string, value: unknown): string | null {
const text = htmlToText(value);
return text ? `${label}: ${text}` : null;
}
/**
* 공연상세 → 상세 컬럼.
* 소개는 줄거리(sty)에 출연·관람 시간·관람 연령을 줄로 붙인다(따로 둘 컬럼이 없다).
* 예매처(relates)는 여러 곳일 수 있어 첫 번째 http(s) 링크를 쓴다.
*/
export function kopisDetail(db: KopisItem): KopisDetail {
const overview = [
htmlToText(db.sty),
labeled('출연', db.prfcast),
labeled('관람 시간', db.prfruntime),
labeled('관람 연령', db.prfage),
]
.filter((line): line is string => line !== null)
.join('\n');
const relates = toItems(asRecord(db.relates)?.relate);
const bookingUrl =
relates.map((r) => extractUrl(r.relateurl)).find((u) => u !== null) ?? null;
const venueId = str(db, 'mt10id');
const startDate = kopisDate(str(db, 'prfpdfrom'));
const endDate = kopisDate(str(db, 'prfpdto'));
const validPeriod =
startDate !== null && endDate !== null && endDate >= startDate;
const title = htmlToText(db.prfnm);
return {
name: title ? stripRegionTag(title) : null,
startDate: validPeriod ? startDate : null,
endDate: validPeriod ? endDate : null,
genre: str(db, 'genrenm'),
eventPlace: htmlToText(db.fcltynm),
feeText: htmlToText(db.pcseguidance),
playtimeText: htmlToText(db.dtguidance),
overview: overview === '' ? null : overview,
bookingUrl,
imageUrl: kopisImageUrl(str(db, 'poster')),
sourceModifiedAt: kopisTimestamp(str(db, 'updatedate')),
venueSourceId: venueId && KOPIS_ID_RE.test(venueId) ? venueId : null,
};
}
export interface KopisVenue {
name: string;
address: string | null;
lat: number | null;
lng: number | null;
phone: string | null;
homepage: string | null;
/** 좌표가 있었지만 주소의 시·도와 맞지 않아 비웠다 */
coordsRejected: boolean;
}
/**
* 공연시설상세 → event_venue 컬럼.
* 좌표(la·lo)는 주소의 시·도 영역 안일 때만 쓴다 — 다른 지역 좌표가 섞여 있었다(2026-11-03).
*/
export function kopisVenue(db: KopisItem, fallbackName: string): KopisVenue {
const address = htmlToText(db.adres);
const lat = Number(str(db, 'la'));
const lng = Number(str(db, 'lo'));
const hasCoords = isInKorea(lat, lng);
const sido = sidoCodeFromAddress(address);
const ok = hasCoords && (sido === null || isInSido(sido, lat, lng));
return {
name: htmlToText(db.fcltynm) ?? fallbackName,
address,
lat: ok ? lat : null,
lng: ok ? lng : null,
phone: htmlToText(db.telno),
homepage: extractUrl(db.relateurl),
coordsRejected: hasCoords && !ok,
};
}
@@ -0,0 +1,585 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { sidoCodeFromAddress } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { EventVenue } from '../entities/event-venue.entity';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync';
import { KOPIS_OPERATIONS } from './kopis-api.client';
import type { KopisApi, KopisItem } from './kopis-api.client';
import {
KopisSkipReason,
kopisDetail,
kopisListItemToEvent,
kopisVenue,
} from './kopis-mapper';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
const SOURCE = 'kopis';
const PAGE_SIZE = 100;
const MAX_PAGES = 30;
const UPSERT_CHUNK = 200;
const MAX_CONSECUTIVE_FAILURES = 10;
/** 목록에 바뀐 표시가 없어도 이 기간이 지나면 상세를 다시 받는다(동기화를 며칠 건너뛴 경우 대비) */
const DETAIL_REFRESH_DAYS = 7;
/** 공연시설 상세(주소·좌표)를 다시 받는 주기 */
const VENUE_REFRESH_DAYS = 30;
const DAY_MS = 86_400_000;
/**
* 목록 upsert 때 덮어쓰는 컬럼. 장소 이름(eventPlace)은 상세가 홀 이름까지 주므로 처음 넣을 때만 쓴다.
* 좌표·주소·전화는 공연시설에서 온다(applyVenueLocations).
*/
const LIST_OVERWRITE_PROPS = [
'kind',
'name',
'startDate',
'endDate',
'genre',
'imageUrl',
'imageThumbUrl',
'ldongRegnCd',
'status',
'lastSeenAt',
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason = KopisSkipReason | 'other_region' | 'duplicate';
export interface KopisSyncResult {
regionCode: string;
window: { from: string; to: string };
fetched: number;
inserted: number;
updated: number;
hidden: number;
skipped: Partial<Record<SkipReason, number>>;
/** 어제 이후 KOPIS 에서 등록·수정된 공연 수(afterdate) */
changed: number;
detailed: number;
detailFailed: number;
detailRemaining: number;
venuesSynced: number;
venueFailed: number;
/** 좌표가 주소의 시·도와 맞지 않아 비운 공연시설 */
venueCoordsRejected: string[];
stoppedByBudget: boolean;
calls: Record<string, number>;
errors: string[];
}
const toYmd = (iso: string) => iso.replace(/-/g, '');
const errorText = (error: unknown) =>
error instanceof Error ? error.message : String(error);
/**
* KOPIS 공연 동기화(매일). 오늘부터 N일 안에 열리는 시·도 공연을 받아 festival_event(source='kopis')에 upsert 한다.
* 1. 목록 — 기간이 겹치는 공연. 목록에 없어진 공연은 숨긴다(원본 장애 의심이면 건너뜀)
* 2. 상세 — 새 공연 · 어제 이후 바뀐 공연(afterdate) · 7일 지난 공연만. 요금·공연 시간·예매처·공연시설 id
* 3. 공연시설 — 새 시설과 30일 지난 시설만. 주소·좌표(좌표는 공연 목록·상세에 없다)
* 4. 공연시설의 좌표·주소·전화를 공연에 복사 — 조회 API 가 공연 좌표로 거리를 계산한다
* 출처 표기는 조회 API 가 붙인다(festival.service PROVIDER_LABEL).
*/
@Injectable()
export class KopisSync {
private readonly logger = new Logger(KopisSync.name);
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
@InjectRepository(EventVenue)
private readonly venues: Repository<EventVenue>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<KopisSyncResult[]> {
const results: KopisSyncResult[] = [];
for (const regionCode of options.regionCodes) {
const result = await this.run({ regionCode, days: options.days });
results.push(result);
if (result.stoppedByBudget) break; // 한도는 시·도가 함께 쓴다
}
return results;
}
async run(options: {
regionCode: string;
days?: number;
}): Promise<KopisSyncResult> {
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.kopisDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createKopis(budget);
const run = await this.recorder.start('kopis', regionCode);
const result: KopisSyncResult = {
regionCode,
window,
fetched: 0,
inserted: 0,
updated: 0,
hidden: 0,
skipped: {},
changed: 0,
detailed: 0,
detailFailed: 0,
detailRemaining: 0,
venuesSynced: 0,
venueFailed: 0,
venueCoordsRejected: [],
stoppedByBudget: false,
calls: {},
errors: [],
};
try {
await this.syncList(api, regionCode, window, result);
const changed = result.stoppedByBudget
? new Set<string>()
: await this.changedSinceYesterday(api, regionCode, window, result);
if (!result.stoppedByBudget) {
await this.syncDetails(
api,
budget,
regionCode,
window.from,
changed,
result,
);
}
if (!result.stoppedByBudget) {
await this.syncVenues(api, budget, regionCode, window.from, result);
}
await this.applyVenueLocations(regionCode);
result.detailRemaining = await this.detailPendingQuery(
regionCode,
window.from,
new Set(),
).getCount();
} catch (error) {
result.errors.unshift(errorText(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,
...result.venueCoordsRejected.map(
(name) => `${name}: 좌표가 주소(시·도)와 맞지 않아 비움`,
),
],
});
this.report(result);
}
return result;
}
/** 1. 목록 → upsert, 목록에서 없어진 공연 숨김 */
private async syncList(
api: KopisApi,
regionCode: string,
window: { from: string; to: string },
result: KopisSyncResult,
): Promise<void> {
const runStart = new Date();
const existing = await this.events.find({
where: { source: SOURCE },
select: {
id: true,
sourceId: true,
status: true,
ldongRegnCd: true,
startDate: true,
endDate: true,
},
});
const bySourceId = new Map(existing.map((e) => [e.sourceId, e]));
const rows = new Map<string, Partial<FestivalEvent>>();
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let items: KopisItem[];
try {
items = await api.listPerformances({
stdate: toYmd(window.from),
eddate: toYmd(window.to),
signgucode: regionCode,
cpage: page,
rows: PAGE_SIZE,
});
} 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 = kopisListItemToEvent(item);
if ('skip' in mapped) {
this.skip(result, mapped.skip);
continue;
}
const { area, ...columns } = mapped;
// 지역 조건을 서버가 무시하더라도 다른 시·도 공연은 넣지 않는다
const sido = sidoCodeFromAddress(area);
if (sido !== null && sido !== regionCode) {
this.skip(result, 'other_region');
continue;
}
if (rows.has(mapped.sourceId)) {
this.skip(result, 'duplicate'); // 쪽 경계에서 같은 공연이 두 번 올 수 있다
continue;
}
rows.set(mapped.sourceId, {
...columns,
source: SOURCE,
kind: 'performance',
ldongRegnCd: regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
});
if (bySourceId.has(mapped.sourceId)) result.updated += 1;
else result.inserted += 1;
}
if (items.length < PAGE_SIZE) {
complete = true;
break;
}
}
await this.upsert([...rows.values()]);
if (!complete) return;
// 받은 기간과 겹치는데 이번 목록에 없는 공연은 숨긴다(취소·삭제)
const inWindowBefore = existing.filter(
(e) =>
e.status === 'active' &&
e.ldongRegnCd === regionCode &&
e.endDate >= window.from &&
e.startDate <= window.to,
);
const missing = inWindowBefore.filter((e) => !rows.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('source = :source', { source: SOURCE })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
result.hidden = hidden.affected ?? 0;
}
private skip(result: KopisSyncResult, reason: SkipReason): void {
result.skipped[reason] = (result.skipped[reason] ?? 0) + 1;
}
private async upsert(rows: Partial<FestivalEvent>[]): Promise<void> {
const overwrite = LIST_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', 'source_id'])
.execute();
}
}
/**
* 어제(한국 날짜) 이후 등록·수정된 공연 id — 목록에는 수정일이 없어 따로 묻는다.
* 실패해도 동기화는 계속한다(7일 주기 재조회가 받쳐 준다).
*/
private async changedSinceYesterday(
api: KopisApi,
regionCode: string,
window: { from: string; to: string },
result: KopisSyncResult,
): Promise<Set<string>> {
const changed = new Set<string>();
const afterdate = toYmd(kstDateString(new Date(), -1));
try {
for (let page = 1; page <= MAX_PAGES; page += 1) {
const items = await api.listPerformances({
stdate: toYmd(window.from),
eddate: toYmd(window.to),
signgucode: regionCode,
cpage: page,
rows: PAGE_SIZE,
afterdate,
});
for (const item of items) {
if (typeof item.mt20id === 'string') changed.add(item.mt20id.trim());
}
if (items.length < PAGE_SIZE) break;
}
} catch (error) {
if (error instanceof BudgetExceededError) result.stoppedByBudget = true;
result.errors.push(`최근 수정 공연 조회 실패: ${errorText(error)}`);
}
result.changed = changed.size;
return changed;
}
/** 상세를 받아야 하는 공연 — 새 공연 · 바뀐 공연 · 오래된 공연 */
private detailPendingQuery(
regionCode: string,
today: string,
changed: Set<string>,
) {
const qb = this.events
.createQueryBuilder('e')
.select(['e.id', 'e.sourceId'])
.where('e.source = :source', { source: SOURCE })
.andWhere('e.ldong_regn_cd = :region', { region: regionCode })
.andWhere('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :today', { today });
const stale = new Date(Date.now() - DETAIL_REFRESH_DAYS * DAY_MS);
if (changed.size > 0) {
qb.andWhere(
'(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale OR e.source_id IN (:...changed))',
{ stale, changed: [...changed] },
);
} else {
qb.andWhere(
'(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale)',
{
stale,
},
);
}
return qb.orderBy('e.start_date', 'ASC').addOrderBy('e.id', 'ASC');
}
/** 2. 공연상세 → 요금·공연 시간·소개·예매처·공연시설 연결 */
private async syncDetails(
api: KopisApi,
budget: CallBudget,
regionCode: string,
today: string,
changed: Set<string>,
result: KopisSyncResult,
): Promise<void> {
const pending = await this.detailPendingQuery(
regionCode,
today,
changed,
).getMany();
const venueIds = new Map<string, string>();
let consecutiveFailures = 0;
for (const event of pending) {
if (!budget.canSpend(KOPIS_OPERATIONS.detail)) {
result.stoppedByBudget = true;
break;
}
try {
const db = await api.getPerformance(event.sourceId);
if (!db) throw new Error('상세가 비어 있음');
const d = kopisDetail(db);
const venueId = d.venueSourceId
? await this.ensureVenue(
d.venueSourceId,
d.eventPlace ?? '공연장',
venueIds,
)
: null;
await this.events.update(event.id, {
// 상세가 주는 값은 상세가 기준이다. 이름·기간·장르·포스터는 비어 있으면 목록 값을 둔다
...(d.name ? { name: d.name } : {}),
...(d.startDate && d.endDate
? { startDate: d.startDate, endDate: d.endDate }
: {}),
...(d.genre ? { genre: d.genre } : {}),
...(d.eventPlace ? { eventPlace: d.eventPlace } : {}),
...(d.imageUrl
? { imageUrl: d.imageUrl, imageThumbUrl: d.imageUrl }
: {}),
feeText: d.feeText,
playtimeText: d.playtimeText,
overview: d.overview,
bookingUrl: d.bookingUrl,
sourceModifiedAt: d.sourceModifiedAt,
venueId,
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}: ${errorText(error)}`);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
/**
* 공연시설 행을 찾거나 이름만으로 만든다(주소·좌표는 3단계에서 채운다).
* 이미 있는 시설은 건드리지 않는다.
*/
private async ensureVenue(
mt10id: string,
fallbackName: string,
cache: Map<string, string>,
): Promise<string> {
const cached = cache.get(mt10id);
if (cached) return cached;
await this.venues
.createQueryBuilder()
.insert()
.into(EventVenue)
.values({ source: SOURCE, sourceId: mt10id, name: fallbackName })
.orIgnore()
.execute();
const venue = await this.venues.findOneOrFail({
where: { source: SOURCE, sourceId: mt10id },
select: { id: true },
});
cache.set(mt10id, venue.id);
return venue.id;
}
/** 3. 공연시설 상세 — 진행 중·예정 공연이 쓰는 시설 중 새 시설과 오래된 시설만 */
private async syncVenues(
api: KopisApi,
budget: CallBudget,
regionCode: string,
today: string,
result: KopisSyncResult,
): Promise<void> {
const stale = new Date(Date.now() - VENUE_REFRESH_DAYS * DAY_MS);
const pending = await this.venues
.createQueryBuilder('v')
.select(['v.id', 'v.sourceId', 'v.name'])
.where('v.source = :source', { source: SOURCE })
.andWhere('(v.synced_at IS NULL OR v.synced_at < :stale)', { stale })
.andWhere(
`EXISTS (SELECT 1 FROM festival_event e WHERE e.venue_id = v.id AND e.source = :source
AND e.status = 'active' AND e.end_date >= :today AND e.ldong_regn_cd = :region)`,
{ today, region: regionCode },
)
.orderBy('v.source_id', 'ASC')
.getMany();
let consecutiveFailures = 0;
for (const venue of pending) {
if (!budget.canSpend(KOPIS_OPERATIONS.venue)) {
result.stoppedByBudget = true;
break;
}
try {
const db = await api.getVenue(venue.sourceId);
if (!db) throw new Error('시설 상세가 비어 있음');
const { coordsRejected, ...columns } = kopisVenue(db, venue.name);
await this.venues.update(venue.id, {
...columns,
syncedAt: new Date(),
});
if (coordsRejected) result.venueCoordsRejected.push(columns.name);
result.venuesSynced += 1;
consecutiveFailures = 0;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
result.venueFailed += 1;
consecutiveFailures += 1;
result.errors.push(`시설 ${venue.sourceId}: ${errorText(error)}`);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`시설 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
/** 4. 공연시설의 좌표·주소·전화를 공연에 복사한다(시설 좌표를 고치면 다음 실행에 공연도 바뀐다) */
private async applyVenueLocations(regionCode: string): Promise<void> {
await this.events.query(
`UPDATE festival_event e
SET lat = v.lat, lng = v.lng, address = v.address, phone = v.phone, updated_at = now()
FROM event_venue v
WHERE e.venue_id = v.id AND e.source = $1 AND e.ldong_regn_cd = $2 AND v.synced_at IS NOT NULL
AND (e.lat IS DISTINCT FROM v.lat OR e.lng IS DISTINCT FROM v.lng
OR e.address IS DISTINCT FROM v.address OR e.phone IS DISTINCT FROM v.phone)`,
[SOURCE, regionCode],
);
}
private report(r: KopisSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`KOPIS 공연 ${r.regionCode} (${r.window.from} ~ ${r.window.to}) ${r.fetched}건 → 새 공연 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` +
(skipped ? ` · 건너뜀(${skipped})` : ''),
);
this.logger.log(
`상세 ${r.detailed}건 받음(어제 이후 바뀐 공연 ${r.changed}) · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` +
` / 공연시설 ${r.venuesSynced}곳 받음 · 실패 ${r.venueFailed}` +
(r.stoppedByBudget
? ' — 오늘 한도에 닿아 멈춤, 내일 다시 실행하면 이어서 받는다'
: ''),
);
for (const name of r.venueCoordsRejected) {
this.logger.warn(
`${name}: 좌표가 주소(시·도)와 맞지 않아 비움 — 근처 조회에서 빠진다`,
);
}
this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
}
}
@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import {
MERGE_MAX_DISTANCE_M,
MERGE_TARGET_CATEGORIES,
sqlValues,
} from '../place.constants';
import { METERS_PER_LAT_DEGREE, sqlDistanceM } from '../place.sql';
import { isSameShopName } from '../shop-name';
/** 1차 거르기 사각형 — 한국 최북단 위도(약 39.5°)에서도 반경을 담도록 경도 폭을 넉넉히 잡는다 */
const BOX_LAT = MERGE_MAX_DISTANCE_M / METERS_PER_LAT_DEGREE;
const BOX_LNG =
MERGE_MAX_DISTANCE_M /
(METERS_PER_LAT_DEGREE * Math.cos((39.5 * Math.PI) / 180));
interface CandidatePair {
store_id: string;
store_name: string;
tour_id: string;
tour_name: string;
distance_m: number;
}
export interface DedupeResult {
candidates: number;
merged: number;
}
/**
* 같은 가게를 관광공사·상가정보가 둘 다 가진 경우, 상가정보 쪽을 관광공사 쪽에 병합한다(merged_into_id).
* 관광공사 쪽에 영업시간·사진·소개가 있으므로 그쪽을 남긴다.
* 규칙: 관광공사 쪽이 카페·식당·주점이고, 100m 안이며, 상호가 같거나 한쪽이 다른 쪽을 포함(2글자 이상).
* 매번 전부 다시 계산한다(관광공사 장소가 숨겨지거나 이름이 바뀌면 병합도 풀린다).
*/
@Injectable()
export class PlaceDedupe {
private readonly logger = new Logger(PlaceDedupe.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
) {}
async run(): Promise<DedupeResult> {
const dist = sqlDistanceM('t.lat', 't.lng', 's.lat', 's.lng');
const pairs = await this.places.manager.query<CandidatePair[]>(
`SELECT s.id AS store_id, s.name AS store_name, t.id AS tour_id, t.name AS tour_name, ${dist} AS distance_m
FROM place s
JOIN place t
ON t.source = 'tour' AND t.status = 'active'
AND t.category IN (${sqlValues(MERGE_TARGET_CATEGORIES)})
AND abs(s.lat - t.lat) < $1 AND abs(s.lng - t.lng) < $2
WHERE s.source = 'store' AND s.status = 'active' AND ${dist} <= $3`,
[BOX_LAT, BOX_LNG, MERGE_MAX_DISTANCE_M],
);
// 상가정보 업소마다 상호가 같은 가장 가까운 관광공사 장소 하나
const best = new Map<string, { tourId: string; distance: number }>();
for (const p of pairs) {
if (!isSameShopName(p.store_name, p.tour_name)) continue;
const prev = best.get(p.store_id);
const distance = Number(p.distance_m);
if (!prev || distance < prev.distance) {
best.set(p.store_id, { tourId: p.tour_id, distance });
}
}
await this.places.manager.transaction(async (tx) => {
// 원본 updated_at 을 건드리지 않도록 직접 UPDATE 한다
await tx.query(
`UPDATE place SET merged_into_id = NULL WHERE source = 'store' AND merged_into_id IS NOT NULL`,
);
for (const [storeId, { tourId }] of best) {
await tx.query(`UPDATE place SET merged_into_id = $1 WHERE id = $2`, [
tourId,
storeId,
]);
}
});
this.logger.log(
`중복 병합: 후보 ${pairs.length}쌍 중 같은 가게 ${best.size}곳을 관광공사 장소에 병합`,
);
return { candidates: pairs.length, merged: best.size };
}
}
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { parseOpeningHours } from '@/shared/utils/opening-hours';
import { stableStringify } from '@/shared/utils/stable-json';
import { PlaceCuration } from '../entities/place-curation.entity';
import { Place } from '../entities/place.entity';
export interface ReparseResult {
places: number;
placesChanged: number;
curations: number;
curationsChanged: number;
byStatus: Record<string, number>;
}
/**
* 저장된 영업시간 원문을 지금의 파서로 다시 해석한다(공공데이터 API 를 다시 부르지 않는다).
* 파서를 고친 뒤 한 번 실행한다. 원본 갱신 시각(updated_at)은 건드리지 않는다.
*/
@Injectable()
export class PlaceHoursReparse {
private readonly logger = new Logger(PlaceHoursReparse.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
@InjectRepository(PlaceCuration)
private readonly curations: Repository<PlaceCuration>,
) {}
async run(): Promise<ReparseResult> {
const result: ReparseResult = {
places: 0,
placesChanged: 0,
curations: 0,
curationsChanged: 0,
byStatus: {},
};
const places = await this.places
.createQueryBuilder('p')
.select([
'p.id',
'p.publicHoursText',
'p.publicRestText',
'p.publicHours',
])
.where(
'p.public_hours_text IS NOT NULL OR p.public_rest_text IS NOT NULL',
)
.getMany();
const curations = await this.curations
.createQueryBuilder('c')
.select(['c.placeId', 'c.hoursText', 'c.restText', 'c.hours'])
.where('c.hours_text IS NOT NULL OR c.rest_text IS NOT NULL')
.getMany();
await this.places.manager.transaction(async (tx) => {
for (const p of places) {
const next = parseOpeningHours(p.publicHoursText, p.publicRestText);
result.places += 1;
result.byStatus[next.status] = (result.byStatus[next.status] ?? 0) + 1;
if (stableStringify(next) === stableStringify(p.publicHours)) continue;
await tx.query(`UPDATE place SET public_hours = $1 WHERE id = $2`, [
JSON.stringify(next),
p.id,
]);
result.placesChanged += 1;
}
for (const c of curations) {
const next = parseOpeningHours(c.hoursText, c.restText);
result.curations += 1;
if (stableStringify(next) === stableStringify(c.hours)) continue;
await tx.query(
`UPDATE place_curation SET hours = $1 WHERE place_id = $2`,
[JSON.stringify(next), c.placeId],
);
result.curationsChanged += 1;
}
});
this.logger.log(
`영업시간 다시 해석: 장소 ${result.places}곳 중 ${result.placesChanged}곳 바뀜 · 큐레이션 ${result.curations}건 중 ${result.curationsChanged}건 바뀜 · 해석 결과 ${JSON.stringify(result.byStatus)}`,
);
return result;
}
}
@@ -0,0 +1,111 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import { SyncRunRecorder } from './sync-run.recorder';
import { PublicDataApiFactory } from './public-data.factory';
/** 관광공사 국문 적재에 쓰는 오퍼레이션 */
const TOUR_KOR_OPERATIONS = [
'areaBasedList2',
'detailCommon2',
'detailIntro2',
'detailPetTour2',
];
export interface PlaceSyncStatusReport {
byStatus: Record<string, number>;
byCategory: Record<string, number>;
detailPending: number;
withPet: number;
usedToday: Record<string, number>;
dailyLimit: number;
store: {
byStatus: Record<string, number>;
byCategory: Record<string, number>;
snapshot: string | null;
};
}
/** 적재 진행 상황 — 지역별 장소·업소 수, 상세 남은 수, 오늘 쓴 호출 수 */
@Injectable()
export class PlaceSyncStatus {
private readonly logger = new Logger(PlaceSyncStatus.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
async report(regionCode: string): Promise<PlaceSyncStatusReport> {
const count = async (
column: 'status' | 'category',
source: 'tour' | 'store' = 'tour',
) => {
const rows = await this.places
.createQueryBuilder('p')
.select(`p.${column}`, 'key')
.addSelect('COUNT(*)', 'n')
.where('p.source = :source', { source })
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
.groupBy(`p.${column}`)
.getRawMany<{ key: string; n: string }>();
return Object.fromEntries(rows.map((r) => [r.key, Number(r.n)]));
};
const pendingAndPet = await this.places
.createQueryBuilder('p')
.select(
`COUNT(*) FILTER (WHERE p.status = 'active' AND p.detail_synced_at IS NULL)`,
'pending',
)
.addSelect(
`COUNT(*) FILTER (WHERE p.public_pet_policy IS NOT NULL)`,
'pet',
)
.where('p.source = :source', { source: 'tour' })
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
.getRawOne<{ pending: string; pet: string }>();
const report: PlaceSyncStatusReport = {
byStatus: await count('status'),
byCategory: await count('category'),
detailPending: Number(pendingAndPet?.pending ?? 0),
withPet: Number(pendingAndPet?.pet ?? 0),
usedToday: await this.recorder.usedToday(),
dailyLimit: this.apiFactory.tourDailyLimit(),
store: {
byStatus: await count('status', 'store'),
byCategory: await count('category', 'store'),
snapshot: await this.storeSnapshot(regionCode),
},
};
this.logger.log(
`지역 ${regionCode} 관광공사 장소 — 상태 ${JSON.stringify(report.byStatus)}`,
);
this.logger.log(`분류 ${JSON.stringify(report.byCategory)}`);
this.logger.log(
`상세 남은 장소 ${report.detailPending} · 반려동물 정보 있는 장소 ${report.withPet}`,
);
const used = TOUR_KOR_OPERATIONS.map(
(op) => `${op} ${report.usedToday[op] ?? 0}/${report.dailyLimit}`,
).join(' · ');
this.logger.log(`오늘(KST) 이 저장소에서 쓴 호출 — ${used}`);
this.logger.log(
`상가정보 업소(기준 ${report.store.snapshot ?? '없음'}) — 상태 ${JSON.stringify(report.store.byStatus)} · 분류 ${JSON.stringify(report.store.byCategory)}`,
);
return report;
}
private async storeSnapshot(regionCode: string): Promise<string | null> {
const row = await this.places
.createQueryBuilder('p')
.select('MAX(p.source_snapshot)', 'snapshot')
.where('p.source = :source', { source: 'store' })
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
.getRawOne<{ snapshot: string | null }>();
return row?.snapshot ?? null;
}
}
@@ -0,0 +1,60 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventVenue } from '../entities/event-venue.entity';
import { FestivalEvent } from '../entities/festival-event.entity';
import { PlaceCuration } from '../entities/place-curation.entity';
import { Place } from '../entities/place.entity';
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
import { FestivalSync } from './festival.sync';
import { KcisaSync } from './kcisa.sync';
import { KopisSync } from './kopis.sync';
import { PlaceDedupe } from './place-dedupe';
import { PlaceHoursReparse } from './place-hours-reparse';
import { PlaceSyncStatus } from './place-sync-status';
import { SyncRunRecorder } from './sync-run.recorder';
import { PublicDataApiFactory } from './public-data.factory';
import { TourKorSync } from './tour-kor.sync';
import { StoreSync } from './store.sync';
import { TourPetSync } from './tour-pet.sync';
/**
* 장소·행사 동기화 — 공공데이터를 place·festival_event 에 적재한다.
* API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Place,
PlaceCuration,
PlaceSyncRun,
FestivalEvent,
EventVenue,
]),
],
providers: [
PublicDataApiFactory,
SyncRunRecorder,
TourKorSync,
TourPetSync,
StoreSync,
FestivalSync,
KopisSync,
KcisaSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
],
exports: [
TourKorSync,
FestivalSync,
KopisSync,
KcisaSync,
TourPetSync,
StoreSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
],
})
export class PlaceSyncModule {}
@@ -0,0 +1,181 @@
import { BudgetExceededError, CallBudget } from './call-budget';
/**
* 공공데이터 공통 호출 규칙 — 관광공사·상가정보(apis.data.go.kr)·KOPIS 클라이언트가 함께 쓴다.
* 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다), 간격을 두고, 네트워크 오류·5xx 만 다시 시도한다.
* **키는 어떤 오류 메시지에도 남기지 않는다.** 응답 해석은 API 마다 달라 하위 클래스가 맡는다.
*/
const REQUEST_TIMEOUT_MS = 20_000;
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1_000;
const DEFAULT_CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
/** 게이트웨이가 한도 초과로 거부할 때의 표시(오류 코드 22) */
export const QUOTA_EXCEEDED_RE =
/LIMITED_NUMBER_OF_SERVICE_REQUESTS|<returnReasonCode>\s*22\s*</i;
export type PublicDataItem = Record<string, unknown>;
export class PublicDataApiError extends Error {
constructor(message: string) {
super(message);
this.name = 'PublicDataApiError';
}
}
interface FetchResponse {
ok: boolean;
status: number;
text(): Promise<string>;
}
export type FetchLike = (
url: string,
init: { signal: AbortSignal },
) => Promise<FetchResponse>;
export interface PublicDataClientOptions {
serviceKey: string;
budget: CallBudget;
baseUrl?: string;
fetchImpl?: FetchLike;
sleep?: (ms: number) => Promise<void>;
}
/** API 마다 다른 호출 규칙 — 키 파라미터 이름, 호출 간격 */
export interface PublicDataClientSettings {
/** 키를 싣는 쿼리 파라미터(공공데이터포털 serviceKey · KOPIS service) */
keyParam?: string;
/** 연속 호출 최소 간격(ms) */
intervalMs?: number;
}
const defaultSleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
export function asRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/** 배열·객체 1건·빈 값 어느 쪽으로 와도 항목 배열로 바꾼다 */
export function toItems(value: unknown): PublicDataItem[] {
const list = Array.isArray(value) ? value : value ? [value] : [];
return list.map(asRecord).filter((x): x is PublicDataItem => x !== undefined);
}
/**
* JSON 이 아닌 응답(키 오류·한도 초과 시 게이트웨이가 XML 로 답한다)을 오류로 바꾼다.
* JSON 이면 파싱 결과를 돌려준다.
*/
export function parseJsonOrThrow(body: string, operation: string): unknown {
try {
return JSON.parse(body);
} catch {
if (QUOTA_EXCEEDED_RE.test(body)) {
throw new BudgetExceededError(operation, 'server');
}
const reason = /<returnAuthMsg>([^<]+)</i.exec(body)?.[1];
throw new PublicDataApiError(
`${operation}: JSON 이 아닌 응답${reason ? ` (${reason})` : ''}`,
);
}
}
export abstract class PublicDataClient<Page> {
private readonly fetchImpl: FetchLike;
private readonly sleep: (ms: number) => Promise<void>;
private readonly keyParam: string;
private readonly intervalMs: number;
private lastCallAt = 0;
protected constructor(
private readonly options: PublicDataClientOptions,
private readonly baseUrl: string,
private readonly commonParams: Record<string, string>,
settings: PublicDataClientSettings = {},
) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.sleep = options.sleep ?? defaultSleep;
this.keyParam = settings.keyParam ?? 'serviceKey';
this.intervalMs = settings.intervalMs ?? DEFAULT_CALL_INTERVAL_MS;
}
/** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */
protected abstract parse(body: string, operation: string): Page;
/**
* @param operation 한도·오류 메시지에 쓰는 이름
* @param path 주소 경로 — 없으면 operation. KOPIS 상세처럼 경로에 id 가 들어가는 호출에 쓴다
*/
async get(
operation: string,
params: Record<string, string | number>,
path: string = operation,
): Promise<Page> {
const query = new URLSearchParams({
[this.keyParam]: this.options.serviceKey,
...this.commonParams,
...Object.fromEntries(
Object.entries(params).map(([k, v]) => [k, String(v)]),
),
});
const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
this.options.budget.take(operation);
await this.pace();
try {
const res = await this.fetchImpl(url, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const body = await res.text();
if (res.status >= 500) {
lastError = new PublicDataApiError(
`${operation}: HTTP ${res.status}`,
);
} else if (!res.ok) {
if (QUOTA_EXCEEDED_RE.test(body)) {
throw new BudgetExceededError(operation, 'server');
}
throw new PublicDataApiError(`${operation}: HTTP ${res.status}`);
} else {
return this.parse(body, operation);
}
} catch (error) {
if (
error instanceof BudgetExceededError ||
error instanceof PublicDataApiError
) {
throw this.mask(error);
}
lastError = error; // 네트워크 오류·시간 초과 → 다시 시도
}
if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt);
}
const reason =
lastError instanceof Error ? lastError.message : String(lastError);
throw this.mask(
new PublicDataApiError(
`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`,
),
);
}
private async pace(): Promise<void> {
const wait = this.lastCallAt + this.intervalMs - Date.now();
if (wait > 0) await this.sleep(wait);
this.lastCallAt = Date.now();
}
/** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */
private mask<E extends Error>(error: E): E {
const key = this.options.serviceKey;
for (const variant of [key, encodeURIComponent(key)]) {
if (variant) error.message = error.message.split(variant).join('***');
}
return error;
}
}
@@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { parsePositiveInt } from '@/shared/utils/env';
import { CallBudget } from './call-budget';
import { KcisaApi, KcisaApiClient } from './kcisa-api.client';
import { KopisApi, KopisApiClient } from './kopis-api.client';
import { StoreApi, StoreApiClient } from './store-api.client';
import { TourApi, TourApiClient } from './tour-api.client';
/** 관광공사 개발 계정의 오퍼레이션별 하루 한도. 운영 계정 승인 후 TOUR_API_DAILY_LIMIT 로 올린다 */
const DEFAULT_TOUR_DAILY_LIMIT = 1_000;
/** 상가정보 개발 계정 하루 한도(대구 3개 구 적재는 30회 안팎) */
const STORE_DAILY_LIMIT = 10_000;
/** 한국문화정보원 한눈에보는문화정보 개발 계정 하루 한도(대구·경북 매일 실행은 20회 안팎) */
const KCISA_DAILY_LIMIT = 10_000;
/**
* KOPIS 오퍼레이션별 하루 상한 — 공식 한도는 밝혀져 있지 않아 보수적으로 둔다.
* 대구·경북 첫 적재가 상세 250회·시설 100회 안팎이다(2026-11-03 진단: 대구 153 · 경북 91 공연).
*/
const KOPIS_DAILY_LIMIT = 1_000;
/** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */
const SAFETY_MARGIN = 20;
/**
* 공공데이터 클라이언트를 만든다 — 공공데이터포털 키 하나로 관광공사·상가정보·한국문화정보원을, KOPIS 는 KOPIS 키로 부른다.
* 테스트에서는 이 프로바이더를 가짜로 바꿔 끼운다.
*/
@Injectable()
export class PublicDataApiFactory {
constructor(private readonly config: ConfigService) {}
/** 관광공사 오퍼레이션별 이번 실행 상한(하루 한도 − 여유분) */
tourDailyLimit(): number {
const limit = parsePositiveInt(
this.config.get<string>('TOUR_API_DAILY_LIMIT'),
DEFAULT_TOUR_DAILY_LIMIT,
);
return Math.max(0, limit - SAFETY_MARGIN);
}
storeDailyLimit(): number {
return STORE_DAILY_LIMIT - SAFETY_MARGIN;
}
createTour(budget: CallBudget): TourApi {
return new TourApiClient({ serviceKey: this.serviceKey(), budget });
}
createStore(budget: CallBudget): StoreApi {
return new StoreApiClient({ serviceKey: this.serviceKey(), budget });
}
kcisaDailyLimit(): number {
return KCISA_DAILY_LIMIT - SAFETY_MARGIN;
}
createKcisa(budget: CallBudget): KcisaApi {
return new KcisaApiClient({ serviceKey: this.serviceKey(), budget });
}
kopisDailyLimit(): number {
return KOPIS_DAILY_LIMIT - SAFETY_MARGIN;
}
createKopis(budget: CallBudget): KopisApi {
return new KopisApiClient({
serviceKey: this.requireKey(
'KOPIS_SERVICE_KEY',
'KOPIS 에서 발급받은 서비스키',
),
budget,
});
}
private serviceKey(): string {
return this.requireKey(
'PUBLIC_DATA_SERVICE_KEY',
'공공데이터포털 Decoding 키',
);
}
private requireKey(name: string, description: string): string {
const key = this.config.get<string>(name)?.trim();
if (!key || /change[-_]?me/i.test(key)) {
// 키 값은 어떤 경우에도 출력하지 않는다
throw new Error(
`${name} 가 설정되지 않았습니다 — backend/.env.{환경} 에 ${description}를 넣으세요`,
);
}
return key;
}
}
@@ -0,0 +1,24 @@
import { isSuspiciousRemoval } from './removal-guard';
describe('isSuspiciousRemoval', () => {
it('항목이 적은 곳의 정상적인 한두 건 취소는 막지 않는다', () => {
expect(isSuspiciousRemoval(3, 1)).toBe(false);
expect(isSuspiciousRemoval(5, 2)).toBe(false);
});
it('기존 항목이 전부 사라지면(빈 응답) 원본 장애로 본다', () => {
expect(isSuspiciousRemoval(3, 3)).toBe(true);
expect(isSuspiciousRemoval(5, 5)).toBe(true);
});
it('큰 목록은 5건을 넘고 20% 를 넘게 사라질 때만 의심한다', () => {
expect(isSuspiciousRemoval(1174, 10)).toBe(false);
expect(isSuspiciousRemoval(1174, 400)).toBe(true);
expect(isSuspiciousRemoval(20, 6)).toBe(true);
expect(isSuspiciousRemoval(40, 6)).toBe(false);
});
it('처음 적재(기존 0)는 의심하지 않는다', () => {
expect(isSuspiciousRemoval(0, 0)).toBe(false);
});
});
@@ -0,0 +1,20 @@
/**
* 원본 장애 판단 (순수 함수) — 동기화가 "목록에서 사라졌다"고 숨김·폐업 처리하기 전에 쓴다.
* 비율만 보면 항목이 적은 곳(축제 몇 건, 동네 빙수집 5곳)에서 정상적인 한 건 취소도 막히고,
* 개수만 보면 큰 목록이 통째로 덜 오는 장애를 못 잡는다. 그래서 둘 다 본다.
*/
/** 이 비율을 넘게 사라지면 의심한다 */
export const REMOVAL_MAX_RATIO = 0.2;
/** 이 건수 이하로 사라지는 것은 정상 변동으로 본다 */
export const REMOVAL_MIN_ABSOLUTE = 5;
/**
* @param before 이번 동기화 전에 노출 중이던 수
* @param missing 그중 이번 목록에 없는 수
* @returns true 면 원본 장애일 수 있으니 숨김·폐업 처리를 건너뛴다
*/
export function isSuspiciousRemoval(before: number, missing: number): boolean {
if (before > 0 && missing >= before) return true; // 전부 사라짐(빈 응답 등)
return missing > REMOVAL_MIN_ABSOLUTE && missing > before * REMOVAL_MAX_RATIO;
}
@@ -0,0 +1,66 @@
import { BudgetExceededError } from './call-budget';
import { PublicDataApiError } from './public-data.client';
import { parseStorePage } from './store-api.client';
const store = {
bizesId: 'MA0101202210A0001234',
bizesNm: '어느카페',
indsSclsCd: 'I21201',
lon: '128.5940',
lat: '35.8702',
};
describe('parseStorePage', () => {
it('최상위 header·body 구조에서 목록·총건수·기준년월을 읽는다', () => {
const body = JSON.stringify({
header: {
resultCode: '00',
resultMsg: 'NORMAL SERVICE',
stdrYm: '202509',
},
body: { items: [store], numOfRows: 1000, pageNo: 1, totalCount: 1 },
});
expect(parseStorePage(body, 'storeListInDong')).toEqual({
items: [store],
totalCount: 1,
snapshot: '202509',
});
});
it('response 로 감싸 오거나 item 아래에 있어도 읽는다', () => {
const body = JSON.stringify({
response: {
header: { resultCode: '00', stdrYm: '202509' },
body: { items: { item: store }, totalCount: 1 },
},
});
expect(parseStorePage(body, 'storeListInDong').items).toEqual([store]);
});
it('데이터 없음(03)은 오류가 아니라 빈 목록이다', () => {
const body = JSON.stringify({
header: { resultCode: '03', resultMsg: 'NODATA_ERROR' },
});
expect(parseStorePage(body, 'storeListInDong')).toEqual({
items: [],
totalCount: 0,
snapshot: null,
});
});
it('한도 초과(22)는 한도 오류, 그 밖의 코드는 일반 오류다', () => {
const quota = JSON.stringify({ header: { resultCode: '22' } });
expect(() => parseStorePage(quota, 'storeListInDong')).toThrow(
BudgetExceededError,
);
const bad = JSON.stringify({
header: {
resultCode: '10',
resultMsg: 'INVALID_REQUEST_PARAMETER_ERROR',
},
});
expect(() => parseStorePage(bad, 'storeListInDong')).toThrow(
PublicDataApiError,
);
});
});
@@ -0,0 +1,83 @@
import { BudgetExceededError } from './call-budget';
import {
PublicDataApiError,
PublicDataClient,
PublicDataClientOptions,
PublicDataItem,
asRecord,
parseJsonOrThrow,
toItems,
} from './public-data.client';
/** 소상공인시장진흥공단 상가(상권)정보 */
export const STORE_API_BASE_URL =
'https://apis.data.go.kr/B553077/api/open/sdsc2';
/** 결과 코드: 00 정상 · 03 데이터 없음 · 22 한도 초과 */
const OK = '00';
const NO_DATA = '03';
const QUOTA_EXCEEDED = '22';
export type StoreItem = PublicDataItem;
export interface StorePage {
items: StoreItem[];
totalCount: number;
/** 기준년월(header.stdrYm, 예 '202509') — 분기마다 바뀐다 */
snapshot: string | null;
}
/** 동기화가 쓰는 상가정보 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface StoreApi {
get(
operation: string,
params: Record<string, string | number>,
): Promise<StorePage>;
}
/**
* 상가정보 응답 → 목록. 관광공사와 달리 `{ header, body: { items: [...] } }` 가 최상위에 온다
* (혹시 response 로 감싸 와도 읽는다). 데이터 없음(03)은 오류가 아니라 빈 목록이다.
*/
export function parseStorePage(body: string, operation: string): StorePage {
const root = asRecord(parseJsonOrThrow(body, operation));
const wrapper = asRecord(root?.response) ?? root;
const header = asRecord(wrapper?.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
const snapshot =
typeof header?.stdrYm === 'string' && header.stdrYm.trim() !== ''
? header.stdrYm.trim()
: null;
if (code === NO_DATA) return { items: [], totalCount: 0, snapshot };
if (code !== '' && code !== OK) {
if (code === QUOTA_EXCEEDED) {
throw new BudgetExceededError(operation, 'server');
}
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
);
}
const resBody = asRecord(wrapper?.body);
const rawItems = resBody?.items;
const items = toItems(asRecord(rawItems)?.item ?? rawItems);
const totalCount = Number(resBody?.totalCount ?? items.length);
return {
items,
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
snapshot,
};
}
export class StoreApiClient
extends PublicDataClient<StorePage>
implements StoreApi
{
constructor(options: PublicDataClientOptions) {
super(options, STORE_API_BASE_URL, { type: 'json' });
}
protected parse(body: string, operation: string): StorePage {
return parseStorePage(body, operation);
}
}
@@ -0,0 +1,60 @@
import { storeItemToPlace } from './store-mapper';
const item = (over: Record<string, unknown> = {}) => ({
bizesId: 'MA0101202210A0001234',
bizesNm: '어느카페',
brchNm: '동성로점',
indsSclsCd: 'I21201',
indsSclsNm: '카페',
ctprvnCd: '27',
signguCd: '27110',
adongCd: '27110565',
adongNm: '성내1동',
rdnmAdr: '대구광역시 중구 동성로2길 1',
lnoAdr: '대구광역시 중구 삼덕동1가 1',
lon: '128.5940',
lat: '35.8702',
...over,
});
describe('storeItemToPlace', () => {
it('업소를 place 기본 컬럼으로 바꾼다(lon=경도, lat=위도, 시군구 5자리는 둘로 나눔)', () => {
expect(storeItemToPlace(item(), '202509')).toEqual({
source: 'store',
sourceId: 'MA0101202210A0001234',
category: 'cafe',
sourceCategory: 'I21201',
name: '어느카페',
branchName: '동성로점',
addressRoad: '대구광역시 중구 동성로2길 1',
addressJibun: '대구광역시 중구 삼덕동1가 1',
lat: 35.8702,
lng: 128.594,
ldongRegnCd: '27',
ldongSignguCd: '110',
adongCd: '27110565',
sourceSnapshot: '202509',
});
});
it('요리 주점·생맥주는 bar, 빵/도넛은 cafe 다', () => {
expect(
storeItemToPlace(item({ indsSclsCd: 'I21104' }), null),
).toMatchObject({ category: 'bar' });
expect(
storeItemToPlace(item({ indsSclsCd: 'I21001' }), null),
).toMatchObject({ category: 'cafe' });
});
it('데이트 업종이 아니거나 좌표·ID 가 없으면 건너뛴다', () => {
expect(storeItemToPlace(item({ indsSclsCd: 'I20101' }), null)).toEqual({
skip: 'no_category',
});
expect(storeItemToPlace(item({ lat: '', lon: '' }), null)).toEqual({
skip: 'bad_coords',
});
expect(storeItemToPlace(item({ bizesId: '' }), null)).toEqual({
skip: 'no_id',
});
});
});
@@ -0,0 +1,81 @@
import { isInKorea } from '@/shared/utils/geo';
import { htmlToText } from '@/shared/utils/html-text';
import type { PlaceCategory } from '../place.constants';
import type { StoreItem } from './store-api.client';
/**
* 상가정보 업소 → place 컬럼 변환 (순수 함수).
* 적재하는 업종은 데이트 업종 소분류 6개뿐이다(2026-10-29 점검에서 정함, 유흥주점 제외).
* 영업시간·전화·반려동물 정보는 상가정보에 없다 — 앱에는 "영업시간 미확인"으로 나가고 큐레이션으로 채운다.
*/
export const STORE_CATEGORY_BY_CODE: Record<string, PlaceCategory> = {
I21201: 'cafe', // 카페
I21001: 'cafe', // 빵/도넛
I21008: 'cafe', // 아이스크림/빙수
I21005: 'cafe', // 샌드위치/샐러드
I21104: 'bar', // 요리 주점
I21103: 'bar', // 생맥주 전문
};
export const STORE_DATE_CODES = Object.keys(STORE_CATEGORY_BY_CODE);
export interface StorePlace {
source: 'store';
sourceId: string;
category: PlaceCategory;
sourceCategory: string;
name: string;
branchName: string | null;
addressRoad: string | null;
addressJibun: string | null;
lat: number;
lng: number;
ldongRegnCd: string | null;
ldongSignguCd: string | null;
adongCd: string | null;
sourceSnapshot: string | null;
}
export type StoreSkipReason = 'no_id' | 'no_category' | 'bad_coords';
function str(item: StoreItem, 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;
}
/** 업소 1건 → place 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function storeItemToPlace(
item: StoreItem,
snapshot: string | null,
): StorePlace | { skip: StoreSkipReason } {
const sourceId = str(item, 'bizesId');
const name = htmlToText(item.bizesNm);
if (!sourceId || !name) return { skip: 'no_id' };
const code = str(item, 'indsSclsCd');
const category = code ? STORE_CATEGORY_BY_CODE[code] : undefined;
if (!code || !category) return { skip: 'no_category' };
const lat = Number(str(item, 'lat'));
const lng = Number(str(item, 'lon'));
if (!isInKorea(lat, lng)) return { skip: 'bad_coords' };
// 시군구 코드는 5자리(시도 2 + 시군구 3) — place 는 법정동 체계처럼 둘로 나눠 둔다
const signgu = str(item, 'signguCd');
return {
source: 'store',
sourceId,
category,
sourceCategory: code,
name,
branchName: htmlToText(item.brchNm),
addressRoad: str(item, 'rdnmAdr'),
addressJibun: str(item, 'lnoAdr'),
lat,
lng,
ldongRegnCd: str(item, 'ctprvnCd') ?? signgu?.slice(0, 2) ?? null,
ldongSignguCd: signgu && signgu.length === 5 ? signgu.slice(2) : null,
adongCd: str(item, 'adongCd'),
sourceSnapshot: snapshot,
};
}
@@ -0,0 +1,288 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { PlaceDedupe } from './place-dedupe';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import type { StoreItem } from './store-api.client';
import {
STORE_DATE_CODES,
StorePlace,
StoreSkipReason,
storeItemToPlace,
} from './store-mapper';
import { SyncRunRecorder } from './sync-run.recorder';
/** 대구 중구·남구·수성구 — 1차 큐레이션 동네(도심·앞산·수성못)가 속한 구 */
export const DEFAULT_STORE_SIGNGU = ['27110', '27200', '27260'];
const PAGE_SIZE = 1_000; // 매뉴얼 최대값
const MAX_PAGES = 30;
const UPSERT_CHUNK = 200;
/** upsert 때 덮어쓰는 컬럼 — 큐레이션은 다른 테이블이라 건드리지 않는다 */
const OVERWRITE_PROPS = [
'category',
'sourceCategory',
'name',
'branchName',
'addressRoad',
'addressJibun',
'lat',
'lng',
'ldongRegnCd',
'ldongSignguCd',
'adongCd',
'sourceSnapshot',
'status',
'lastSeenAt',
'updatedAt',
] as const satisfies readonly (keyof Place)[];
export interface StoreGroupResult {
signgu: string;
code: string;
fetched: number;
totalCount: number;
complete: boolean;
gone: number;
}
export interface StoreSyncResult {
fetched: number;
inserted: number;
updated: number;
gone: number;
/** 관광공사 장소에 병합된(같은 가게로 본) 업소 수 */
merged: number;
skipped: Partial<Record<StoreSkipReason, number>>;
snapshot: string | null;
groups: StoreGroupResult[];
stoppedByBudget: boolean;
calls: Record<string, number>;
errors: string[];
}
/**
* 소상공인 상가정보 동기화(분기마다). 시군구 × 데이트 업종 소분류로 storeListInDong 을 받아 place 에 upsert 한다.
* 다음 분기에 사라진 업소는 gone(폐업 추정)으로 숨긴다 — 지우지 않는다.
*/
@Injectable()
export class StoreSync {
private readonly logger = new Logger(StoreSync.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
private readonly dedupe: PlaceDedupe,
) {}
async run(options: { signguCodes: string[] }): Promise<StoreSyncResult> {
const budget = new CallBudget(
this.apiFactory.storeDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createStore(budget);
const run = await this.recorder.start(
'store',
options.signguCodes.join(','),
);
const result: StoreSyncResult = {
fetched: 0,
inserted: 0,
updated: 0,
gone: 0,
merged: 0,
skipped: {},
snapshot: null,
groups: [],
stoppedByBudget: false,
calls: {},
errors: [],
};
try {
const runStart = new Date();
const existing = await this.places.find({
where: { source: 'store' },
select: {
id: true,
sourceId: true,
status: true,
ldongRegnCd: true,
ldongSignguCd: true,
sourceCategory: true,
},
});
const known = new Set(existing.map((p) => p.sourceId));
const rows = new Map<string, StorePlace & Partial<Place>>();
outer: for (const signgu of options.signguCodes) {
for (const code of STORE_DATE_CODES) {
const group: StoreGroupResult = {
signgu,
code,
fetched: 0,
totalCount: 0,
complete: false,
gone: 0,
};
result.groups.push(group);
for (let page = 1; page <= MAX_PAGES; page += 1) {
let items: StoreItem[];
try {
const res = await api.get('storeListInDong', {
divId: 'signguCd',
key: signgu,
indsSclsCd: code,
numOfRows: PAGE_SIZE,
pageNo: page,
});
items = res.items;
group.totalCount = res.totalCount;
result.snapshot = res.snapshot ?? result.snapshot;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break outer;
}
throw error;
}
group.fetched += items.length;
result.fetched += items.length;
for (const item of items) {
const mapped = storeItemToPlace(item, result.snapshot);
if ('skip' in mapped) {
result.skipped[mapped.skip] =
(result.skipped[mapped.skip] ?? 0) + 1;
continue;
}
if (!rows.has(mapped.sourceId)) {
rows.set(mapped.sourceId, {
...mapped,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
});
}
}
if (items.length === 0 || group.fetched >= group.totalCount) {
group.complete = true;
break;
}
}
}
}
for (const id of rows.keys()) {
if (known.has(id)) result.updated += 1;
else result.inserted += 1;
}
await this.upsert([...rows.values()]);
await this.markGone(existing, result, new Set(rows.keys()), runStart);
result.merged = (await this.dedupe.run()).merged;
} 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,
gone: result.gone,
calls: result.calls,
snapshot: result.snapshot,
errors: result.errors,
});
this.report(result);
}
return result;
}
private async upsert(rows: Partial<Place>[]): Promise<void> {
const overwrite = OVERWRITE_PROPS.map(
(prop) =>
this.places.metadata.findColumnWithPropertyName(prop)!.databaseName,
);
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
await this.places
.createQueryBuilder()
.insert()
.into(Place)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source', 'source_id'])
.execute();
}
}
/** 끝까지 받은 (시군구, 업종) 묶음에서만, 이번에 보이지 않은 노출 업소를 gone 으로 바꾼다 */
private async markGone(
existing: Place[],
result: StoreSyncResult,
seenIds: Set<string>,
runStart: Date,
): Promise<void> {
for (const group of result.groups.filter((g) => g.complete)) {
const regn = group.signgu.slice(0, 2);
const signgu = group.signgu.slice(2);
const activeBefore = existing.filter(
(p) =>
p.status === 'active' &&
p.ldongRegnCd === regn &&
p.ldongSignguCd === signgu &&
p.sourceCategory === group.code,
);
// 업종이 바뀐 업소(카페 → 빵집)는 다른 묶음에서 보였으므로 사라진 것으로 세지 않는다
const missing = activeBefore.filter(
(p) => !seenIds.has(p.sourceId),
).length;
if (isSuspiciousRemoval(activeBefore.length, missing)) {
result.errors.push(
`${group.signgu}/${group.code}: 기존 ${activeBefore.length}곳 중 ${missing}곳이 목록에 없어 원본 장애로 보고 폐업 처리를 건너뜀`,
);
continue;
}
const updated = await this.places
.createQueryBuilder()
.update(Place)
.set({ status: 'gone' })
.where('source = :source', { source: 'store' })
.andWhere('ldong_regn_cd = :regn', { regn })
.andWhere('ldong_signgu_cd = :signgu', { signgu })
.andWhere('source_category = :code', { code: group.code })
.andWhere('status = :active', { active: 'active' })
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
group.gone = updated.affected ?? 0;
result.gone += group.gone;
}
}
private report(r: StoreSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`상가정보(기준 ${r.snapshot ?? '?'}) ${r.fetched}건 → 새 업소 ${r.inserted} · 갱신 ${r.updated} · 폐업 추정 ${r.gone} · 중복 병합 ${r.merged}` +
(skipped ? ` · 건너뜀(${skipped})` : ''),
);
const incomplete = r.groups.filter((g) => !g.complete);
if (incomplete.length > 0 || r.stoppedByBudget) {
this.logger.warn(
`끝까지 받지 못한 묶음 ${incomplete.length}개 — 그 묶음은 폐업 처리를 하지 않았다`,
);
}
this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
}
}
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThanOrEqual, Repository } from 'typeorm';
import { kstStartOfDay } from '@/shared/utils/kst';
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
import type { SyncJob } from '../place.constants';
import { sumCalls } from './call-budget';
/** 실행 기록에 남기는 오류 수 상한 */
const MAX_RECORDED_ERRORS = 20;
/** place_sync_run 기록 — 실행 시작·종료와 "오늘(KST) 이미 쓴 호출 수" */
@Injectable()
export class SyncRunRecorder {
constructor(
@InjectRepository(PlaceSyncRun)
private readonly runs: Repository<PlaceSyncRun>,
) {}
start(job: SyncJob, scope: string | null): Promise<PlaceSyncRun> {
return this.runs.save(
this.runs.create({ job, scope, startedAt: new Date() }),
);
}
/**
* 오늘(한국 날짜) 이 저장소에서 기록된 호출 수를 오퍼레이션별로 합친다.
* 같은 키를 다른 도구(점검 스크립트 등)에서 쓴 호출은 여기에 없다 — 그 경우는 서버의 한도 초과 응답으로 멈춘다.
*/
async usedToday(now = new Date()): Promise<Record<string, number>> {
const today = await this.runs.find({
where: { startedAt: MoreThanOrEqual(kstStartOfDay(now)) },
select: { calls: true },
});
return sumCalls(today.map((r) => r.calls));
}
async finish(
run: PlaceSyncRun,
result: Partial<
Pick<
PlaceSyncRun,
| 'fetched'
| 'inserted'
| 'updated'
| 'hidden'
| 'gone'
| 'calls'
| 'snapshot'
>
> & { errors?: string[] },
): Promise<void> {
await this.runs.update(run.id, {
...result,
errors: (result.errors ?? []).slice(0, MAX_RECORDED_ERRORS),
finishedAt: new Date(),
});
}
}
@@ -0,0 +1,130 @@
import { BudgetExceededError, CallBudget } from './call-budget';
import { FetchLike, PublicDataApiError } from './public-data.client';
import { TourApiClient, parseTourPage } from './tour-api.client';
const KEY = 'abc+def/ghi==';
const okBody = (item: unknown, totalCount = 1) =>
JSON.stringify({
response: {
header: { resultCode: '0000', resultMsg: 'OK' },
body: { items: item === '' ? '' : { item }, totalCount },
},
});
function client(
responses: Array<{ status?: number; body?: string; throws?: Error }>,
budget = new CallBudget(100),
) {
const urls: string[] = [];
const fetchImpl: FetchLike = (url) => {
urls.push(url);
const next = responses.shift();
if (!next) throw new Error('예상보다 많이 호출했다');
if (next.throws) return Promise.reject(next.throws);
const status = next.status ?? 200;
return Promise.resolve({
ok: status < 400,
status,
text: () => Promise.resolve(next.body ?? ''),
});
};
const api = new TourApiClient({
serviceKey: KEY,
budget,
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls, budget };
}
describe('parseTourPage', () => {
it('항목이 1건이면 객체로, 0건이면 빈 문자열로 와도 배열로 바꾼다', () => {
expect(parseTourPage(okBody({ contentid: '1' }), 'x').items).toEqual([
{ contentid: '1' },
]);
expect(parseTourPage(okBody('', 0), 'x')).toEqual({
items: [],
totalCount: 0,
});
});
it('결과 코드가 0000 이 아니면 오류로 본다', () => {
const body = JSON.stringify({
response: { header: { resultCode: '10', resultMsg: 'INVALID_REQUEST' } },
});
expect(() => parseTourPage(body, 'detailIntro2')).toThrow(
/detailIntro2: 결과 코드 10 INVALID_REQUEST/,
);
});
it('한도 초과 응답(XML, 코드 22)은 한도 오류로 본다', () => {
const xml =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR</returnAuthMsg><returnReasonCode>22</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseTourPage(xml, 'detailCommon2')).toThrow(
BudgetExceededError,
);
});
it('키 오류 같은 XML 응답은 사유를 담은 오류로 본다', () => {
const xml =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseTourPage(xml, 'areaBasedList2')).toThrow(
/SERVICE_KEY_IS_NOT_REGISTERED_ERROR/,
);
});
});
describe('TourApiClient', () => {
it('공통 파라미터와 인코딩된 키를 붙여 호출하고 한도를 차감한다', async () => {
const { api, urls, budget } = client([
{ body: okBody({ contentid: '7' }) },
]);
const page = await api.get('detailCommon2', { contentId: 7 });
expect(page.items[0]).toEqual({ contentid: '7' });
const url = new URL(urls[0]);
expect(url.pathname).toMatch(/\/KorService2\/detailCommon2$/);
expect(url.searchParams.get('serviceKey')).toBe(KEY);
expect(url.searchParams.get('_type')).toBe('json');
expect(url.searchParams.get('contentId')).toBe('7');
expect(budget.spent()).toEqual({ detailCommon2: 1 });
});
it('5xx·네트워크 오류는 다시 시도하고, 재시도도 한도에서 차감한다', async () => {
const { api, budget } = client([
{ status: 503 },
{ throws: new Error('socket hang up') },
{ body: okBody({ contentid: '1' }) },
]);
await expect(api.get('areaBasedList2', {})).resolves.toMatchObject({
totalCount: 1,
});
expect(budget.spent()).toEqual({ areaBasedList2: 3 });
});
it('한도가 없으면 호출하지 않고 BudgetExceededError 를 던진다', async () => {
const { api, urls } = client([], new CallBudget(0));
await expect(api.get('detailIntro2', {})).rejects.toThrow(
BudgetExceededError,
);
expect(urls).toHaveLength(0);
});
it('오류 메시지에 키가 섞여도 가린다', async () => {
// 최종 메시지는 마지막 시도의 오류로 만든다 — 그 오류에 키가 원문·인코딩 둘 다 섞인 경우
const { api } = client([
{ throws: new Error('connect failed') },
{ throws: new Error('connect failed') },
{
throws: new Error(
`connect failed ?serviceKey=${encodeURIComponent(KEY)} (${KEY})`,
),
},
]);
const error = await api.get('detailCommon2', {}).catch((e: Error) => e);
expect(error).toBeInstanceOf(PublicDataApiError);
expect((error as Error).message).toContain('***');
expect((error as Error).message).not.toContain(KEY);
expect((error as Error).message).not.toContain(encodeURIComponent(KEY));
});
});
@@ -0,0 +1,73 @@
import { BudgetExceededError } from './call-budget';
import {
PublicDataApiError,
PublicDataClient,
PublicDataClientOptions,
PublicDataItem,
asRecord,
parseJsonOrThrow,
toItems,
} from './public-data.client';
/** 관광공사 국문 관광정보 서비스(KorService2, 매뉴얼 v4.4) */
export const TOUR_API_BASE_URL = 'https://apis.data.go.kr/B551011/KorService2';
const TOUR_COMMON_PARAMS = {
MobileOS: 'ETC',
MobileApp: 'WanderGo',
_type: 'json',
};
export type TourItem = PublicDataItem;
export interface TourPage {
items: TourItem[];
totalCount: number;
}
/** 동기화가 쓰는 관광공사 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface TourApi {
get(
operation: string,
params: Record<string, string | number>,
): Promise<TourPage>;
}
/**
* 관광공사 응답 → 목록. `{ response: { header, body: { items: { item } } } }` 구조이고,
* 항목이 1건이면 배열 대신 객체를, 0건이면 빈 문자열을 준다. 정상 코드는 '0000'.
*/
export function parseTourPage(body: string, operation: string): TourPage {
const response = asRecord(
asRecord(parseJsonOrThrow(body, operation))?.response,
);
const header = asRecord(response?.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
if (code !== '' && code !== '0000') {
if (code === '22') throw new BudgetExceededError(operation, 'server');
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
);
}
const resBody = asRecord(response?.body);
const items = toItems(asRecord(resBody?.items)?.item);
const totalCount = Number(resBody?.totalCount ?? items.length);
return {
items,
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
};
}
export class TourApiClient
extends PublicDataClient<TourPage>
implements TourApi
{
constructor(options: PublicDataClientOptions) {
super(options, TOUR_API_BASE_URL, TOUR_COMMON_PARAMS);
}
protected parse(body: string, operation: string): TourPage {
return parseTourPage(body, operation);
}
}
@@ -0,0 +1,364 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import type { PlaceCategory } from '../place.constants';
import { BudgetExceededError, CallBudget } from './call-budget';
import { PlaceDedupe } from './place-dedupe';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
import { PublicDataApiFactory } from './public-data.factory';
import {
INTRO_FIELDS,
SkipReason,
TourListPlace,
commonToDetail,
introToDetail,
listItemToPlace,
} from './tour-mapper';
/** 법정동 시·도 코드 — 1차 출시 지역 대구 */
export const DEFAULT_TOUR_REGION = '27';
const LIST_PAGE_SIZE = 1_000; // 2026-11-02 대구 1,193건을 2쪽으로 받음
const MAX_LIST_PAGES = 50;
const UPSERT_CHUNK = 200;
/** 상세 조회가 연속으로 이만큼 실패하면 멈춘다(키·서버 문제일 가능성) */
const MAX_CONSECUTIVE_FAILURES = 10;
/** 상세를 받는 순서 — 한도 때문에 나뉘어도 앱에 먼저 필요한 분류부터 채운다 */
const DETAIL_PRIORITY: PlaceCategory[] = [
'nature',
'attraction',
'culture',
'cafe',
'food',
'leisure',
'bar',
'shopping',
'other',
'lodging',
];
/** 목록 upsert 때 덮어쓰는 컬럼(상세·큐레이션 관련 컬럼은 건드리지 않는다) */
const LIST_PROPS = [
'sourceType',
'category',
'sourceCategory',
'name',
'addressRoad',
'lat',
'lng',
'ldongRegnCd',
'ldongSignguCd',
'phone',
'imageUrl',
'imageThumbUrl',
'imageLicense',
'sourceModifiedAt',
'status',
'lastSeenAt',
'detailSyncedAt',
'updatedAt',
] as const satisfies readonly (keyof Place)[];
export interface TourKorOptions {
regionCode: string;
/** 목록만 갱신하고 상세는 받지 않는다 */
skipDetails?: boolean;
}
export interface TourKorResult {
fetched: number;
totalCount: number;
inserted: number;
updated: number;
hidden: number;
skipped: Partial<Record<SkipReason, number>>;
detailed: number;
detailFailed: number;
detailRemaining: number;
/** 이 장소들에 병합된 상가정보 업소 수 */
merged: number;
stoppedByBudget: boolean;
calls: Record<string, number>;
errors: string[];
}
/**
* 관광공사 국문 장소 동기화(초기 적재 겸 전체 갱신).
* 1) 시·도 지역 목록을 전부 받아 place 에 upsert — 바뀐 장소는 상세를 다시 받도록 표시
* 2) 상세(개요·영업시간)를 한도 안에서 받는다. 남은 것은 다음 실행이 이어서 받는다
* 3) 목록에서 사라진 장소는 hidden (지우지 않는다)
*/
@Injectable()
export class TourKorSync {
private readonly logger = new Logger(TourKorSync.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
private readonly dedupe: PlaceDedupe,
) {}
async run(options: TourKorOptions): Promise<TourKorResult> {
const { regionCode } = options;
const budget = new CallBudget(
this.apiFactory.tourDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createTour(budget);
const run = await this.recorder.start('tour_kor', regionCode);
const result: TourKorResult = {
fetched: 0,
totalCount: 0,
inserted: 0,
updated: 0,
hidden: 0,
skipped: {},
detailed: 0,
detailFailed: 0,
detailRemaining: 0,
merged: 0,
stoppedByBudget: false,
calls: {},
errors: [],
};
try {
await this.syncList(api, regionCode, result);
if (!options.skipDetails && !result.stoppedByBudget) {
await this.syncDetails(api, budget, regionCode, result);
}
result.merged = (await this.dedupe.run()).merged;
result.detailRemaining = await this.places.countBy({
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
});
} 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,
result: TourKorResult,
): Promise<void> {
const runStart = new Date();
const existing = await this.places.find({
where: { source: 'tour' },
select: {
id: true,
sourceId: true,
sourceModifiedAt: true,
detailSyncedAt: true,
status: true,
ldongRegnCd: true,
},
});
const bySourceId = new Map(existing.map((p) => [p.sourceId, p]));
const rows: Array<TourListPlace & Partial<Place>> = [];
let complete = false;
for (let page = 1; page <= MAX_LIST_PAGES; page += 1) {
let items: TourItem[];
try {
const res = await api.get('areaBasedList2', {
lDongRegnCd: regionCode,
numOfRows: LIST_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 = listItemToPlace(item);
if ('skip' in mapped) {
result.skipped[mapped.skip] = (result.skipped[mapped.skip] ?? 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 activeBefore = existing.filter(
(p) => p.status === 'active' && p.ldongRegnCd === regionCode,
);
const missing = activeBefore.filter((p) => !seen.has(p.sourceId)).length;
if (isSuspiciousRemoval(activeBefore.length, missing)) {
result.errors.push(
`기존 노출 장소 ${activeBefore.length}곳 중 ${missing}곳이 목록에 없어 원본 장애로 보고 숨김 처리를 건너뜀`,
);
return;
}
const hidden = await this.places
.createQueryBuilder()
.update(Place)
.set({ status: 'hidden' })
.where('source = :source', { source: 'tour' })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
result.hidden = hidden.affected ?? 0;
}
private async upsert(rows: Partial<Place>[]): Promise<void> {
const overwrite = LIST_PROPS.map(
(prop) =>
this.places.metadata.findColumnWithPropertyName(prop)!.databaseName,
);
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
await this.places
.createQueryBuilder()
.insert()
.into(Place)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source', 'source_id'])
.execute();
}
}
private async syncDetails(
api: TourApi,
budget: CallBudget,
regionCode: string,
result: TourKorResult,
): Promise<void> {
const pending = await this.places.find({
where: {
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
},
select: { id: true, sourceId: true, sourceType: true, category: true },
});
pending.sort(
(a, b) =>
DETAIL_PRIORITY.indexOf(a.category) -
DETAIL_PRIORITY.indexOf(b.category),
);
let consecutiveFailures = 0;
for (const place of pending) {
const typeId = place.sourceType ?? '';
const needIntro = typeId in INTRO_FIELDS;
if (
!budget.canSpend('detailCommon2') ||
(needIntro && !budget.canSpend('detailIntro2'))
) {
result.stoppedByBudget = true;
break;
}
try {
const common = await api.get('detailCommon2', {
contentId: place.sourceId,
});
const intro = needIntro
? await api.get('detailIntro2', {
contentId: place.sourceId,
contentTypeId: typeId,
})
: null;
await this.places.update(place.id, {
...commonToDetail(common.items[0] ?? {}),
...(intro ? introToDetail(typeId, 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(
`${place.sourceId}: ${error instanceof Error ? error.message : String(error)}`,
);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
private report(r: TourKorResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`목록 ${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);
}
}
@@ -0,0 +1,181 @@
import {
commonToDetail,
introToDetail,
listItemToPlace,
petToDetail,
toCategory,
} from './tour-mapper';
// 2026-11-02 대구 후보표에서 실제로 본 항목을 줄인 것
const item = (over: Record<string, unknown> = {}) => ({
contentid: '126536',
contenttypeid: '12',
title: '대구 근대골목(근대로의 여행)',
addr1: '대구광역시 중구 달구벌대로 2029',
addr2: '',
mapx: '128.5867',
mapy: '35.8677',
lDongRegnCd: '27',
lDongSignguCd: '110',
lclsSystm1: 'VE',
lclsSystm2: 'VE04',
lclsSystm3: 'VE040100',
cpyrhtDivCd: 'Type1',
firstimage: 'http://tong.visitkorea.or.kr/a.jpg',
firstimage2: 'http://tong.visitkorea.or.kr/a_s.jpg',
tel: '053-661-2194',
modifiedtime: '20261015093000',
...over,
});
describe('toCategory', () => {
it('소분류·중분류·대분류 순서로 자체 분류를 고른다', () => {
expect(toCategory('12', 'VE', 'VE04', 'VE040300')).toBe('nature'); // 둘레길
expect(toCategory('12', 'VE', 'VE04', 'VE040100')).toBe('attraction'); // 골목길
expect(toCategory('12', 'VE', 'VE03', 'VE030100')).toBe('nature'); // 시민공원
expect(toCategory('14', 'VE', 'VE07', 'VE070100')).toBe('culture'); // 박물관
expect(toCategory('14', 'VE', 'VE09', 'VE090300')).toBe('other'); // 도서관
expect(toCategory('39', 'FD', 'FD05', 'FD050100')).toBe('cafe');
expect(toCategory('39', 'FD', 'FD03', 'FD030100')).toBe('cafe'); // 제과
expect(toCategory('39', 'FD', 'FD04', 'FD040100')).toBe('bar');
expect(toCategory('39', 'FD', 'FD01', 'FD010100')).toBe('food');
expect(toCategory('38', 'SH', 'SH04', 'SH040300')).toBe('shopping'); // 사후면세점
expect(toCategory('32', 'AC', 'AC06', 'AC060200')).toBe('lodging');
expect(toCategory('12', 'NA', 'NA04', 'NA040700')).toBe('nature');
});
it('축제·여행코스는 장소가 아니다', () => {
expect(toCategory('15', 'EV', 'EV01', 'EV010200')).toBeNull();
expect(toCategory('25', null, null, null)).toBeNull();
});
it('분류 코드가 비면 관광타입으로 대신한다', () => {
expect(toCategory('39', null, null, null)).toBe('food');
expect(toCategory('99', null, null, null)).toBeNull();
});
});
describe('listItemToPlace', () => {
it('목록 항목을 place 기본 컬럼으로 바꾼다(mapx=경도, mapy=위도)', () => {
expect(listItemToPlace(item())).toEqual({
source: 'tour',
sourceId: '126536',
sourceType: '12',
category: 'attraction',
sourceCategory: 'VE040100',
name: '대구 근대골목(근대로의 여행)',
addressRoad: '대구광역시 중구 달구벌대로 2029',
lat: 35.8677,
lng: 128.5867,
ldongRegnCd: '27',
ldongSignguCd: '110',
phone: '053-661-2194',
imageUrl: 'http://tong.visitkorea.or.kr/a.jpg',
imageThumbUrl: 'http://tong.visitkorea.or.kr/a_s.jpg',
imageLicense: 'Type1',
sourceModifiedAt: new Date('2026-10-15T00:30:00.000Z'),
});
});
it('축제·좌표 없음·한국 밖 좌표는 건너뛴다', () => {
expect(
listItemToPlace(item({ contenttypeid: '15', lclsSystm1: 'EV' })),
).toEqual({ skip: 'not_place' });
expect(listItemToPlace(item({ mapx: '', mapy: '' }))).toEqual({
skip: 'bad_coords',
});
expect(listItemToPlace(item({ mapx: '35.8', mapy: '128.5' }))).toEqual({
skip: 'bad_coords',
});
});
it('알 수 없는 저작권 유형은 비우고, 주소 두 줄은 이어 붙인다', () => {
const p = listItemToPlace(
item({ cpyrhtDivCd: 'Type9', addr2: '(남산동)' }),
);
expect(p).toMatchObject({
imageLicense: null,
addressRoad: '대구광역시 중구 달구벌대로 2029 (남산동)',
});
});
});
describe('commonToDetail', () => {
it('개요의 HTML 을 지우고 홈페이지 URL 만 꺼낸다', () => {
expect(
commonToDetail({
overview: '근대 건축물이 모인 골목<br>계산성당과 청라언덕',
homepage:
'<a href="https://www.jung.daegu.kr" target="_blank">www.jung.daegu.kr</a>',
}),
).toEqual({
overview: '근대 건축물이 모인 골목\n계산성당과 청라언덕',
homepage: 'https://www.jung.daegu.kr',
});
});
});
describe('introToDetail', () => {
it('관광타입에 맞는 영업시간·쉬는날 필드를 읽고 해석한다(39 음식점)', () => {
const d = introToDetail('39', {
opentimefood: '11:00~21:00<br>(준비 시간 15:30~17:00)',
restdatefood: '매주 월요일',
parkingfood: '가능',
firstmenu: '찜갈비',
usetime: '이 값은 관광지 필드라 읽지 않는다',
});
expect(d.publicHoursText).toBe('11:00~21:00\n(준비 시간 15:30~17:00)');
expect(d.publicRestText).toBe('매주 월요일');
expect(d.publicHours?.status).toBe('parsed');
expect(d.publicHours?.closedWeekdays).toEqual([1]);
expect(d.publicExtra).toEqual({ parking: '가능', firstMenu: '찜갈비' });
});
it('문화시설은 usetimeculture 와 이용요금을 읽는다', () => {
const d = introToDetail('14', {
usetimeculture: '09:00~18:00',
restdateculture: '매주 월요일',
usefee: '무료',
});
expect(d.publicHoursText).toBe('09:00~18:00');
expect(d.publicExtra).toEqual({ fee: '무료' });
});
it('값이 하나도 없으면 해석하지 않는다(미확인)', () => {
expect(introToDetail('12', {})).toEqual({
publicHoursText: null,
publicRestText: null,
publicHours: null,
publicExtra: null,
});
});
});
describe('petToDetail', () => {
it('전구역·일부구역을 요약하고 원문을 남긴다', () => {
expect(
petToDetail({
contentid: '1',
acmpyTypeCd: '전구역 동반가능',
acmpyPsblCpam: '전 견종 동반 가능',
acmpyNeedMtr: '목줄 착용',
etcAcmpyInfo: '',
}),
).toEqual({
publicPet: {
acmpyTypeCd: '전구역 동반가능',
acmpyPsblCpam: '전 견종 동반 가능',
acmpyNeedMtr: '목줄 착용',
},
publicPetPolicy: 'allowed',
});
expect(
petToDetail({ acmpyTypeCd: '일부구역 동반가능' }).publicPetPolicy,
).toBe('partial');
});
it('표시가 애매하면 요약하지 않는다(불가로 단정하지 않음)', () => {
expect(petToDetail({ acmpyTypeCd: '동반불가' }).publicPetPolicy).toBeNull();
expect(petToDetail({ acmpyPsblCpam: '소형견' }).publicPetPolicy).toBeNull();
});
});
@@ -0,0 +1,291 @@
import { isInKorea } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { parseKstTimestamp } from '@/shared/utils/kst';
import { parseOpeningHours } from '@/shared/utils/opening-hours';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import { IMAGE_LICENSES } from '../place.constants';
import type {
ImageLicense,
PlaceCategory,
PublicPetPolicy,
} from '../place.constants';
import type { TourItem } from './tour-api.client';
/**
* 관광공사 응답 → place 컬럼 변환 (순수 함수).
* 분류 대응은 「신분류체계정보 관광타입정보 연계 정의서」(docs/data-openapi/) 의 소분류 240개를 보고 정했다.
*/
/** 장소가 아닌 관광타입 — 15 축제·공연·행사는 festival_event, 25 여행코스는 저장하지 않는다 */
const SKIP_TYPES = new Set(['15', '25']);
/** 소분류 단위 예외 */
const CATEGORY_BY_LCLS3: Record<string, PlaceCategory> = {
VE040300: 'nature', // 둘레길
FD030100: 'cafe', // 제과(빵집) — 상가정보 빵/도넛과 같은 칸
};
/** 중분류 단위 */
const CATEGORY_BY_LCLS2: Record<string, PlaceCategory> = {
VE01: 'attraction', // 랜드마크(건물·전망대·다리…)
VE02: 'attraction', // 테마공원(테마파크·동물원·수족관…)
VE03: 'nature', // 도시공원 — '산책·자연' 탭
VE04: 'attraction', // 골목길·문화거리·마을관광지(둘레길은 위 예외)
VE05: 'attraction', // 관광단지·리조트
VE06: 'culture', // 공연장·영화관
VE07: 'culture', // 박물관·기념관·전시관·미술관
VE08: 'other', // 연회장
VE09: 'other', // 문화원·도서관·학교 등 교육시설
VE10: 'leisure', // 경기장·스포츠센터
VE12: 'culture', // 서점·기타문화시설
FD04: 'bar', // 바·펍·생맥주·전통주점
FD05: 'cafe', // 카페·찻집
};
/** 대분류 단위 */
const CATEGORY_BY_LCLS1: Record<string, PlaceCategory> = {
NA: 'nature', // 자연관광
HS: 'attraction', // 역사관광
VE: 'attraction', // 문화관광(위 중분류에 없는 것)
EX: 'leisure', // 체험관광(공예·템플스테이·웰니스…)
LS: 'leisure', // 레저스포츠
SH: 'shopping', // 쇼핑(백화점·시장·사후면세점…)
FD: 'food', // 음식(카페·주점·제과 제외)
AC: 'lodging', // 숙박
};
/** 분류 코드가 비었을 때 관광타입으로 대신한다 */
const CATEGORY_BY_TYPE: Record<string, PlaceCategory> = {
'12': 'attraction',
'14': 'culture',
'28': 'leisure',
'32': 'lodging',
'38': 'shopping',
'39': 'food',
};
/** 관광타입별 소개정보(detailIntro2) 필드. 32 숙박은 코스 장소가 아니라 MVP 에서는 부르지 않는다 */
export const INTRO_FIELDS: Record<
string,
{ hours: string; rest: string; extra: Record<string, string> }
> = {
'12': {
hours: 'usetime',
rest: 'restdate',
extra: {
parking: 'parking',
infocenter: 'infocenter',
season: 'useseason',
},
},
'14': {
hours: 'usetimeculture',
rest: 'restdateculture',
extra: {
fee: 'usefee',
parking: 'parkingculture',
parkingFee: 'parkingfee',
infocenter: 'infocenterculture',
spendTime: 'spendtime',
},
},
'28': {
hours: 'usetimeleports',
rest: 'restdateleports',
extra: {
fee: 'usefeeleports',
parking: 'parkingleports',
parkingFee: 'parkingfeeleports',
infocenter: 'infocenterleports',
openPeriod: 'openperiod',
reservation: 'reservation',
},
},
'38': {
hours: 'opentime',
rest: 'restdateshopping',
extra: {
parking: 'parkingshopping',
infocenter: 'infocentershopping',
fairDay: 'fairday',
saleItem: 'saleitem',
},
},
'39': {
hours: 'opentimefood',
rest: 'restdatefood',
extra: {
parking: 'parkingfood',
infocenter: 'infocenterfood',
firstMenu: 'firstmenu',
treatMenu: 'treatmenu',
reservation: 'reservationfood',
packing: 'packing',
},
},
};
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;
}
export function toCategory(
typeId: string | null,
lcls1: string | null,
lcls2: string | null,
lcls3: string | null,
): PlaceCategory | null {
if ((typeId && SKIP_TYPES.has(typeId)) || lcls1 === 'EV') return null;
return (
(lcls3 && CATEGORY_BY_LCLS3[lcls3]) ||
(lcls2 && CATEGORY_BY_LCLS2[lcls2]) ||
(lcls1 && CATEGORY_BY_LCLS1[lcls1]) ||
(typeId && CATEGORY_BY_TYPE[typeId]) ||
null
);
}
export interface TourListPlace {
source: 'tour';
sourceId: string;
sourceType: string | null;
category: PlaceCategory;
sourceCategory: string | null;
name: string;
addressRoad: string | null;
lat: number;
lng: number;
ldongRegnCd: string | null;
ldongSignguCd: string | null;
phone: string | null;
imageUrl: string | null;
imageThumbUrl: string | null;
imageLicense: ImageLicense | null;
sourceModifiedAt: Date | null;
}
export type SkipReason = 'not_place' | 'no_category' | 'bad_coords' | 'no_id';
/** 목록(areaBasedList2) 항목 1건 → place 기본 컬럼. 장소가 아니거나 쓸 수 없으면 사유를 돌려준다 */
export function listItemToPlace(
item: TourItem,
): TourListPlace | { skip: SkipReason } {
const sourceId = str(item, 'contentid');
const name = htmlToText(item.title);
if (!sourceId || !name) return { skip: 'no_id' };
const typeId = str(item, 'contenttypeid');
if (typeId && SKIP_TYPES.has(typeId)) return { skip: 'not_place' };
const lcls1 = str(item, 'lclsSystm1');
if (lcls1 === 'EV') return { skip: 'not_place' };
const lcls2 = str(item, 'lclsSystm2');
const lcls3 = str(item, 'lclsSystm3');
const category = toCategory(typeId, lcls1, lcls2, lcls3);
if (!category) return { skip: 'no_category' };
const lat = Number(str(item, 'mapy'));
const lng = Number(str(item, 'mapx'));
if (!isInKorea(lat, lng)) {
return { skip: 'bad_coords' };
}
const license = str(item, 'cpyrhtDivCd');
const address = [str(item, 'addr1'), str(item, 'addr2')]
.filter(Boolean)
.join(' ');
return {
source: 'tour',
sourceId,
sourceType: typeId,
category,
sourceCategory: lcls3 ?? lcls2 ?? lcls1,
name,
addressRoad: address === '' ? null : address,
lat,
lng,
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 TourCommonDetail {
overview: string | null;
homepage: string | null;
}
/** 공통정보(detailCommon2) → 개요·홈페이지 */
export function commonToDetail(item: TourItem): TourCommonDetail {
return {
overview: htmlToText(item.overview),
homepage: extractUrl(item.homepage),
};
}
export interface TourIntroDetail {
publicHoursText: string | null;
publicRestText: string | null;
publicHours: ParsedOpeningHours | null;
publicExtra: Record<string, string> | null;
}
/** 소개정보(detailIntro2) → 영업시간·쉬는날 원문, 해석 결과, 부가 정보(요금·주차 등) */
export function introToDetail(typeId: string, item: TourItem): TourIntroDetail {
const fields = INTRO_FIELDS[typeId];
if (!fields) {
return {
publicHoursText: null,
publicRestText: null,
publicHours: null,
publicExtra: null,
};
}
const hours = htmlToText(item[fields.hours]);
const rest = htmlToText(item[fields.rest]);
const extra: Record<string, string> = {};
for (const [name, key] of Object.entries(fields.extra)) {
const v = htmlToText(item[key]);
if (v) extra[name] = v;
}
return {
publicHoursText: hours,
publicRestText: rest,
publicHours: hours || rest ? parseOpeningHours(hours, rest) : null,
publicExtra: Object.keys(extra).length > 0 ? extra : null,
};
}
export interface TourPetDetail {
publicPet: Record<string, string> | null;
publicPetPolicy: PublicPetPolicy | null;
}
/**
* 반려동물 동반 정보(detailPetTour2) → 원문 + 요약.
* 관광공사 정보는 "동반 가능한 곳"만 담는다 — 표시가 애매하면 요약하지 않고(null) 원문만 둔다.
*/
export function petToDetail(item: TourItem): TourPetDetail {
const raw: Record<string, string> = {};
for (const [key, value] of Object.entries(item)) {
if (key === 'contentid') continue;
const v = htmlToText(value);
if (v) raw[key] = v;
}
const type = raw.acmpyTypeCd ?? '';
let policy: PublicPetPolicy | null = null;
if (/불가/.test(type)) policy = null;
else if (/일부/.test(type)) policy = 'partial';
else if (/전구역|동반\s*가능/.test(type)) policy = 'allowed';
return {
publicPet: Object.keys(raw).length > 0 ? raw : null,
publicPetPolicy: policy,
};
}
@@ -0,0 +1,132 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourItem } from './tour-api.client';
import { PublicDataApiFactory } from './public-data.factory';
import { TourPetDetail, petToDetail } from './tour-mapper';
const PAGE_SIZE = 1_000;
const MAX_PAGES = 30; // 2026-10 전국 약 9,700건
export interface TourPetResult {
fetched: number;
totalCount: number;
updated: number;
cleared: number;
complete: boolean;
calls: Record<string, number>;
}
/**
* 관광공사 반려동물 동반 정보 반영. detailPetTour2 는 contentId 없이 부르면 전국 전체를 준다(약 10회).
* 받은 정보를 place(관광공사) 에 붙이고, 목록에서 빠진 장소는 정보를 지운다(끝까지 받았을 때만).
*/
@Injectable()
export class TourPetSync {
private readonly logger = new Logger(TourPetSync.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
async run(): Promise<TourPetResult> {
const budget = new CallBudget(
this.apiFactory.tourDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createTour(budget);
const run = await this.recorder.start('tour_pet', null);
const result: TourPetResult = {
fetched: 0,
totalCount: 0,
updated: 0,
cleared: 0,
complete: false,
calls: {},
};
const errors: string[] = [];
try {
const byId = new Map<string, TourPetDetail>();
for (let page = 1; page <= MAX_PAGES; page += 1) {
let items: TourItem[];
try {
const res = await api.get('detailPetTour2', {
numOfRows: PAGE_SIZE,
pageNo: page,
});
items = res.items;
result.totalCount = res.totalCount;
} catch (error) {
if (error instanceof BudgetExceededError) {
errors.push(error.message);
break;
}
throw error;
}
result.fetched += items.length;
for (const item of items) {
const raw = item.contentid;
const id =
typeof raw === 'string' || typeof raw === 'number'
? String(raw)
: null;
if (id) byId.set(id, petToDetail(item));
}
if (items.length === 0 || result.fetched >= result.totalCount) {
result.complete = true;
break;
}
}
const places = await this.places.find({
where: { source: 'tour' },
select: {
id: true,
sourceId: true,
publicPet: true,
publicPetPolicy: true,
},
});
for (const place of places) {
const next = byId.get(place.sourceId);
if (!next && !result.complete) continue; // 덜 받았으면 지우지 않는다
const target: TourPetDetail = next ?? {
publicPet: null,
publicPetPolicy: null,
};
const same =
JSON.stringify(place.publicPet) ===
JSON.stringify(target.publicPet) &&
place.publicPetPolicy === target.publicPetPolicy;
if (same) continue;
await this.places.update(place.id, target);
if (next) result.updated += 1;
else result.cleared += 1;
}
} catch (error) {
errors.unshift(error instanceof Error ? error.message : String(error));
throw error;
} finally {
result.calls = budget.spent();
await this.recorder.finish(run, {
fetched: result.fetched,
updated: result.updated + result.cleared,
calls: result.calls,
errors,
});
this.logger.log(
`반려동물 정보 ${result.fetched}/${result.totalCount}건 받음 → 장소 갱신 ${result.updated} · 정보 삭제 ${result.cleared}` +
(result.complete ? '' : ' — 끝까지 받지 못해 삭제는 건너뜀'),
);
for (const e of errors.slice(0, 5)) this.logger.warn(e);
}
return result;
}
}
@@ -1,6 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
import { parsePositiveInt } from '@/shared/utils/env';
/**
@@ -73,6 +74,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
migrationsRun: isProduction,
logging: !isProduction,
extra: { ...POOL_OPTIONS },
// 테이블·컬럼은 snake_case, TS 속성은 camelCase — 엔티티마다 name 을 적지 않는다
namingStrategy: new SnakeNamingStrategy(),
// uuid 기본값을 gen_random_uuid() 로 만든다(pgcrypto, PostgreSQL 13+ 는 기본 내장 함수)
uuidExtension: 'pgcrypto',
entities: paths.entities,
migrations: paths.migrations,
};
@@ -32,6 +32,12 @@ describe('validateEnv', () => {
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
it('관광공사 API 하루 한도가 숫자가 아니면 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', TOUR_API_DAILY_LIMIT: '천' }),
).toThrow(/TOUR_API_DAILY_LIMIT 는 양의 정수/);
});
});
describe('운영 환경', () => {
@@ -24,6 +24,8 @@ const POSITIVE_INT_KEYS = [
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
// 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
'TOUR_API_DAILY_LIMIT',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
@@ -0,0 +1,33 @@
import { SnakeNamingStrategy } from './snake-naming.strategy';
describe('SnakeNamingStrategy', () => {
const strategy = new SnakeNamingStrategy();
it('클래스 이름으로 테이블 이름을 만들고, 직접 지정한 이름은 그대로 쓴다', () => {
expect(strategy.tableName('PlaceCuration', undefined)).toBe(
'place_curation',
);
expect(strategy.tableName('FestivalEvent', 'festival_event')).toBe(
'festival_event',
);
});
it('속성 이름을 snake_case 컬럼으로 바꾸고, 직접 지정한 컬럼 이름은 그대로 쓴다', () => {
expect(strategy.columnName('verifiedAt', undefined, [])).toBe(
'verified_at',
);
expect(strategy.columnName('verifiedAt', 'checked_on', [])).toBe(
'checked_on',
);
});
it('임베디드 접두어를 붙인다', () => {
expect(strategy.columnName('lat', undefined, ['geoPoint'])).toBe(
'geo_point_lat',
);
});
it('관계 조인 컬럼을 snake_case 로 만든다', () => {
expect(strategy.joinColumnName('place', 'id')).toBe('place_id');
});
});
@@ -0,0 +1,52 @@
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { toSnakeCase } from '@/shared/utils/snake-case';
/**
* DB 이름 규칙 — 테이블·컬럼은 snake_case, TS 속성은 camelCase.
* 엔티티마다 `name:` 을 적지 않아도 되게 한 곳에서 바꾼다(앱·CLI 가 database.config.ts 로 공유).
* `name:` 을 직접 지정한 경우에는 그 이름을 그대로 쓴다.
*/
export class SnakeNamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface
{
tableName(targetName: string, userSpecifiedName: string | undefined): string {
return userSpecifiedName ?? toSnakeCase(targetName);
}
columnName(
propertyName: string,
customName: string | undefined,
embeddedPrefixes: string[],
): string {
const prefix = toSnakeCase(embeddedPrefixes.concat('').join('_'));
return prefix + (customName ?? toSnakeCase(propertyName));
}
relationName(propertyName: string): string {
return toSnakeCase(propertyName);
}
joinColumnName(relationName: string, referencedColumnName: string): string {
return toSnakeCase(`${relationName}_${referencedColumnName}`);
}
joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
): string {
return toSnakeCase(
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
);
}
joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return toSnakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { isInKorea, isInSido, sidoCodeFromAddress } from './geo';
describe('isInKorea', () => {
it('대구 좌표는 한국 안이다', () => {
expect(isInKorea(35.8677, 128.5867)).toBe(true);
});
it('위도·경도가 뒤바뀌었거나 0 이면 한국 밖으로 본다', () => {
expect(isInKorea(128.5867, 35.8677)).toBe(false);
expect(isInKorea(0, 0)).toBe(false);
});
it('숫자가 아니면 거른다', () => {
expect(isInKorea(Number.NaN, 128.5)).toBe(false);
});
});
describe('sidoCodeFromAddress', () => {
it('주소 앞머리로 대구·경북 코드를 찾는다', () => {
expect(sidoCodeFromAddress('대구광역시 중구 태평로 141')).toBe('27');
expect(sidoCodeFromAddress('경상북도 경주시 알천북로 1')).toBe('47');
expect(sidoCodeFromAddress(' 경북 포항시 북구')).toBe('47');
});
it('모르는 지역이거나 비어 있으면 null', () => {
expect(sidoCodeFromAddress('부산광역시 해운대구')).toBeNull();
expect(sidoCodeFromAddress(null)).toBeNull();
});
});
describe('isInSido', () => {
it('대구 도심·군위 좌표는 대구 안이다', () => {
expect(isInSido('27', 35.8761, 128.5938)).toBe(true);
expect(isInSido('27', 36.2428, 128.5728)).toBe(true);
});
it('대구 주소에 경주 좌표(2026-11-03 KOPIS 실제 사례)는 대구 밖이다', () => {
expect(isInSido('27', 35.847189, 129.212306)).toBe(false);
expect(isInSido('47', 35.847189, 129.212306)).toBe(true);
});
it('영역을 모르는 시·도는 한국 안인지만 본다', () => {
expect(isInSido('26', 35.1587, 129.1604)).toBe(true);
expect(isInSido('26', 0, 0)).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* 좌표 유틸 (순수 함수).
*/
/** 한국 영역 대략값(제주~백령도·독도 포함) */
const KOREA_BOUNDS = { latMin: 33, latMax: 39.5, lngMin: 124, lngMax: 132 };
/**
* 위도·경도가 한국 안의 정상 좌표인지 본다.
* 공공데이터에는 좌표가 0 이거나 위도·경도가 뒤바뀐 항목이 섞여 있다.
*/
export function isInKorea(lat: number, lng: number): boolean {
const b = KOREA_BOUNDS;
return (
Number.isFinite(lat) &&
Number.isFinite(lng) &&
lat >= b.latMin &&
lat <= b.latMax &&
lng >= b.lngMin &&
lng <= b.lngMax
);
}
interface Bounds {
latMin: number;
latMax: number;
lngMin: number;
lngMax: number;
}
/**
* 시·도 영역 대략값(법정동 시·도 코드 2자리) — 원본 좌표가 주소와 다른 지역을 가리키는지 본다.
* 2026-11-03 KOPIS 공연시설 중 대구 중구 주소인데 경주 좌표(경도 129.21)인 곳이 있었다.
* 동기화 범위(대구·경북)만 둔다. 없는 코드는 한국 영역만 확인한다.
*/
const SIDO_BOUNDS: Record<string, Bounds> = {
// 대구 — 2023년 편입한 군위군 포함
'27': { latMin: 35.55, latMax: 36.36, lngMin: 128.3, lngMax: 128.9 },
// 경북 — 울릉도·독도 포함
'47': { latMin: 35.55, latMax: 37.6, lngMin: 127.75, lngMax: 131.95 },
};
/** 주소 앞머리 → 시·도 코드 (동기화 범위만) */
const SIDO_ADDRESS_PREFIXES: Array<[RegExp, string]> = [
[/^대구/, '27'],
[/^(경상북도|경북)/, '47'],
];
/** 주소('대구광역시 중구 …')에서 시·도 코드를 찾는다. 모르는 지역이면 null */
export function sidoCodeFromAddress(address: string | null): string | null {
const text = address?.trim() ?? '';
return SIDO_ADDRESS_PREFIXES.find(([re]) => re.test(text))?.[1] ?? null;
}
/** 좌표가 그 시·도 안인지 본다. 영역을 모르는 코드면 한국 안인지만 본다 */
export function isInSido(sidoCode: string, lat: number, lng: number): boolean {
if (!isInKorea(lat, lng)) return false;
const b = SIDO_BOUNDS[sidoCode];
if (!b) return true;
return (
lat >= b.latMin && lat <= b.latMax && lng >= b.lngMin && lng <= b.lngMax
);
}
@@ -0,0 +1,61 @@
import { extractUrl, htmlToText } from './html-text';
describe('htmlToText', () => {
it('줄바꿈 태그는 줄바꿈으로, 나머지 태그는 지운다', () => {
expect(htmlToText('주차 가능<br>소형 20대<br />대형 2대')).toBe(
'주차 가능\n소형 20대\n대형 2대',
);
expect(htmlToText('<strong>무료</strong> 입장')).toBe('무료 입장');
});
it('닫는 형태로 잘못 쓴 줄바꿈 태그(</br>)도 줄바꿈으로 바꾼다', () => {
expect(htmlToText('평일 09:00~18:00</br>주말 09:00~17:00')).toBe(
'평일 09:00~18:00\n주말 09:00~17:00',
);
});
it('자주 쓰는 HTML 엔티티를 되돌린다', () => {
expect(htmlToText('A &amp; B &lt;안내&gt;')).toBe('A & B <안내>');
});
it('따옴표·가운뎃점 같은 이름 엔티티와 번호 엔티티도 되돌린다', () => {
expect(
htmlToText('방짜유기박물관 &lsquo;놋&rsquo; 특별전 &middot; 체험'),
).toBe('방짜유기박물관 ‘놋’ 특별전 · 체험');
expect(htmlToText('&#8220;빛&#x201D;')).toBe('“빛”');
});
it('한 번만 되돌린다 — &amp;lt; 는 &lt; 로 남는다', () => {
expect(htmlToText('a &amp;lt; b')).toBe('a &lt; b');
});
it('모르는 엔티티·잘못된 번호는 그대로 둔다', () => {
expect(htmlToText('&unknown; &#99999999;')).toBe('&unknown; &#99999999;');
});
it('빈 값·공백뿐인 값은 null 이다', () => {
expect(htmlToText('')).toBeNull();
expect(htmlToText(' <br> ')).toBeNull();
expect(htmlToText(undefined)).toBeNull();
});
});
describe('extractUrl', () => {
it('a 태그의 href 를 꺼낸다', () => {
expect(
extractUrl(
'<a href="https://tour.daegu.go.kr" target="_blank" title="새창">tour.daegu.go.kr</a>',
),
).toBe('https://tour.daegu.go.kr');
});
it('태그 없이 주소만 있으면 그대로, www 로 시작하면 https 를 붙인다', () => {
expect(extractUrl('http://example.kr/a')).toBe('http://example.kr/a');
expect(extractUrl('www.example.kr')).toBe('https://www.example.kr');
});
it('http(s) 가 아닌 링크는 버린다', () => {
expect(extractUrl('<a href="javascript:alert(1)">x</a>')).toBeNull();
expect(extractUrl('홈페이지 없음')).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* 공공데이터 텍스트 정리 유틸 (순수 함수).
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
*/
/** 이름 엔티티 — 한국문화정보원 제목에 `&amp;lsquo;` 처럼 한 번 더 인코딩된 따옴표·가운뎃점이 온다(2026-11-03) */
const NAMED_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
lsquo: '‘',
rsquo: '’',
ldquo: '“',
rdquo: '”',
middot: '·',
hellip: '…',
ndash: '–',
mdash: '—',
lang: '〈',
rang: '〉',
};
const MAX_CODE_POINT = 0x10ffff;
/** 엔티티 하나를 글자로. 모르는 이름·잘못된 번호는 그대로 둔다 */
function decodeEntity(match: string, body: string): string {
if (body.startsWith('#')) {
const hex = body[1] === 'x' || body[1] === 'X';
const code = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
return Number.isInteger(code) && code > 0 && code <= MAX_CODE_POINT
? String.fromCodePoint(code)
: match;
}
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
}
/**
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
* `<br>`·`</br>`·`</p>` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null.
*/
export function htmlToText(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const text = String(value)
.replace(/<\/?br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, decodeEntity)
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]{2,}/g, ' ')
.trim();
return text === '' ? null : text;
}
/**
* 홈페이지 값에서 URL 만 꺼낸다. 관광공사는 `<a href="…" …>…</a>` 를 통째로 준다.
* http(s) 가 아닌 값은 버린다(javascript: 등). `www.` 로 시작하면 https 를 붙인다.
*/
export function extractUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const href = /href\s*=\s*["']([^"']+)["']/i.exec(value)?.[1];
const raw = (
href ?? /(https?:\/\/[^\s"'<>]+|www\.[^\s"'<>]+)/i.exec(value)?.[1]
)?.trim();
if (!raw) return null;
if (/^https?:\/\//i.test(raw)) return raw;
if (/^www\./i.test(raw)) return `https://${raw}`;
return null;
}
+63
View File
@@ -0,0 +1,63 @@
import {
kstClock,
kstDateString,
kstStartOfDay,
parseKstTimestamp,
} from './kst';
describe('kstStartOfDay', () => {
it('한국 날짜의 0시를 돌려준다', () => {
// 2026-11-02 14:00 KST = 05:00 UTC → 그날 0시 KST = 11-01 15:00 UTC
expect(kstStartOfDay(new Date('2026-11-02T05:00:00Z')).toISOString()).toBe(
'2026-11-01T15:00:00.000Z',
);
});
it('UTC 로는 전날이어도 한국 날짜가 바뀌었으면 새 날로 본다', () => {
// 2026-11-02 00:30 KST = 11-01 15:30 UTC
expect(kstStartOfDay(new Date('2026-11-01T15:30:00Z')).toISOString()).toBe(
'2026-11-01T15:00:00.000Z',
);
});
});
describe('parseKstTimestamp', () => {
it('14자리 한국 시간을 해석한다', () => {
expect(parseKstTimestamp('20261102143000')?.toISOString()).toBe(
'2026-11-02T05:30:00.000Z',
);
});
it('8자리 날짜는 그날 0시(KST)로 본다', () => {
expect(parseKstTimestamp('20261102')?.toISOString()).toBe(
'2026-11-01T15:00:00.000Z',
);
});
it('형식이 다르거나 없는 날짜면 null', () => {
expect(parseKstTimestamp('2026-11-02')).toBeNull();
expect(parseKstTimestamp('20260230')).toBeNull();
expect(parseKstTimestamp('')).toBeNull();
expect(parseKstTimestamp(null)).toBeNull();
});
});
describe('kstClock', () => {
it('한국 시간의 요일과 자정 이후 분을 돌려준다', () => {
// 2026-11-01(일) 15:30 UTC = 2026-11-02(월) 00:30 KST
expect(kstClock(new Date('2026-11-01T15:30:00Z'))).toEqual({
weekday: 1,
minutes: 30,
});
});
});
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');
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* 한국 시간(KST, UTC+9) 유틸 (순수 함수). 한국은 서머타임이 없어 고정 오프셋으로 계산한다.
*/
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/** 주어진 시각이 속한 한국 날짜의 0시(KST)를 Date 로 돌려준다 */
export function kstStartOfDay(now: Date): Date {
const kst = new Date(now.getTime() + KST_OFFSET_MS);
const midnightUtc = Date.UTC(
kst.getUTCFullYear(),
kst.getUTCMonth(),
kst.getUTCDate(),
);
return new Date(midnightUtc - KST_OFFSET_MS);
}
/**
* 'YYYYMMDD' 또는 'YYYYMMDDhhmmss'(한국 시간) 문자열을 Date 로 바꾼다.
* 관광공사 modifiedtime·createdtime 형식이다. 형식이 다르거나 없는 날짜면 null.
*/
export function parseKstTimestamp(value: unknown): Date | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const m = /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?$/.exec(
String(value).trim(),
);
if (!m) return null;
const [y, mo, d, h, mi, s] = m.slice(1).map((v) => Number(v ?? 0));
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
return null;
}
const date = new Date(Date.UTC(y, mo - 1, d, h, mi, s) - KST_OFFSET_MS);
// 2월 30일처럼 넘어가는 날짜는 거른다
const back = new Date(date.getTime() + KST_OFFSET_MS);
return back.getUTCDate() === d ? date : null;
}
/** 한국 시간 기준 요일(0=일요일 … 6=토요일)과 자정 이후 분 — "지금 영업 중" 판단에 쓴다 */
export function kstClock(now: Date): { weekday: number; minutes: number } {
const kst = new Date(now.getTime() + KST_OFFSET_MS);
return {
weekday: kst.getUTCDay(),
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);
}
@@ -0,0 +1,213 @@
import { isOpenAt, parseOpeningHours } from './opening-hours';
// 요일 번호: 0=일요일 … 6=토요일
const SUN = 0;
const MON = 1;
const WED = 3;
const FRI = 5;
const SAT = 6;
const at = (h: number, m = 0) => h * 60 + m;
// 사례는 2026-10-29 공공데이터 점검(서울·대구)에서 실제로 받은 영업시간·쉬는날 문장이다
describe('parseOpeningHours', () => {
it('단순한 시간 구간과 연중무휴를 매일 같은 시간표로 읽는다', () => {
const p = parseOpeningHours('11:00~22:00', '연중무휴');
expect(p.status).toBe('parsed');
expect(
p.week.every((d) => d?.[0]?.start === at(11) && d?.[0]?.end === at(22)),
).toBe(true);
expect(p.closedWeekdays).toEqual([]);
});
it('글머리표와 준비시간(브레이크)을 읽는다', () => {
const p = parseOpeningHours(
'- 11:00~21:00 / - 준비시간 15:00~17:00',
'연중무휴',
);
expect(p.breaks).toEqual([{ start: at(15), end: at(17) }]);
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, WED, at(14))).toBe(true);
});
it('괄호 안의 준비 시간과 마지막 주문을 읽는다', () => {
const a = parseOpeningHours(
'11:00~21:00 (준비 시간 15:30~17:00)',
'연중무휴',
);
expect(a.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
const b = parseOpeningHours('10:00~21:30 (마지막 주문 21:00)', '연중무휴');
expect(b.lastOrder).toBe(at(21));
expect(isOpenAt(b, FRI, at(21, 15))).toBe(true);
});
it('평일에만 있는 준비시간은 주말에 적용하지 않는다', () => {
const p = parseOpeningHours(
'- 11:30~21:00 / - 준비시간(평일) 15:30~16:30 / - 마지막 주문 20:30',
'1월 1일 / 설·추석 연휴',
);
expect(p.breaksWeekdaysOnly).toBe(true);
expect(p.lastOrder).toBe(at(20, 30));
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, SAT, at(16))).toBe(true);
expect(p.holidayClosures).toEqual(
expect.arrayContaining(['설', '추석', '1월 1일']),
);
});
it('정기 휴무 요일은 문을 닫은 것으로 본다', () => {
const p = parseOpeningHours(
'11:30~18:00 / ※ 재료 소진 시 조기 마감',
'매주 일요일',
);
expect(p.closedWeekdays).toEqual([SUN]);
expect(p.week[SUN]).toEqual([]);
expect(isOpenAt(p, SUN, at(12))).toBe(false);
expect(p.notes).toContain('재료 소진 시 조기 마감');
});
it('평일·주말 시간이 다르면 요일별로 나눈다', () => {
const p = parseOpeningHours('평일 11:00~22:00 / 주말 10:00~22:00', '');
expect(isOpenAt(p, SAT, at(10, 30))).toBe(true);
expect(isOpenAt(p, MON, at(10, 30))).toBe(false);
});
it('요일 범위(화~일)와 월요일 휴관을 읽는다', () => {
const p = parseOpeningHours('화~일 10:00~18:00 (월요일 휴관)', '');
expect(p.closedWeekdays).toEqual([MON]);
expect(isOpenAt(p, SUN, at(17))).toBe(true);
expect(isOpenAt(p, MON, at(12))).toBe(false);
});
it('자정을 넘기는 영업은 다음 날 새벽까지 이어진다', () => {
const p = parseOpeningHours('18:00~02:00', '매주 월요일');
expect(p.week[SUN]).toEqual([{ start: at(18), end: at(26) }]);
// 일요일 밤에 열어 월요일 새벽 1시까지 — 월요일이 휴무여도 전날 영업은 이어진다
expect(isOpenAt(p, MON, at(1))).toBe(true);
expect(isOpenAt(p, MON, at(19))).toBe(false);
});
it('상시 개방과 24시간은 항상 연 것으로 본다', () => {
expect(parseOpeningHours('상시 개방').status).toBe('always_open');
const p = parseOpeningHours('24시간', '연중무휴');
expect(p.status).toBe('always_open');
expect(isOpenAt(p, WED, at(3))).toBe(true);
});
it('점포별 상이는 정할 수 없다고 표시한다(지어내지 않는다)', () => {
expect(parseOpeningHours('점포별 상이').status).toBe('varies');
const p = parseOpeningHours('※ 점포별 상이하므로 홈페이지 참고');
expect(p.status).toBe('varies');
expect(isOpenAt(p, SAT, at(12))).toBeNull();
});
it('값이 없으면 미확인으로 둔다', () => {
expect(parseOpeningHours(null).status).toBe('unknown');
expect(parseOpeningHours('').status).toBe('unknown');
expect(isOpenAt(parseOpeningHours(undefined), SAT, at(12))).toBeNull();
});
it('계절마다 다른 시간은 어느 계절인지 몰라 시각 판단을 하지 않는다', () => {
const p = parseOpeningHours(
'하절기 화~일 10:00~17:00 / 동절기 화~일 11:00~16:00',
);
expect(p.seasonal).toBe(true);
expect(isOpenAt(p, WED, at(12))).toBeNull();
});
it('줄바꿈 태그와 안내 문구를 정리한다', () => {
const p = parseOpeningHours('10:00~17:00<br>\n※ 방문 전 연락 필수');
expect(p.status).toBe('parsed');
expect(p.notes).toContain('방문 전 연락 필수');
});
it('쉬는날 앞의 보이지 않는 문자(BOM)를 지운다', () => {
const p = parseOpeningHours(
'16:00~23:00',
`${String.fromCharCode(0xfeff)}설·추석 당일`,
);
expect(p.holidayClosures).toEqual(['설', '추석']);
});
it('"시설 점검" 같은 말은 설 휴무로 보지 않는다', () => {
const p = parseOpeningHours('09:00~18:00', '시설 점검일 별도 공지');
expect(p.holidayClosures).toEqual([]);
});
// 2026-11-03 대구 관광공사 원문 — 구간이 구분 없이 붙어 오거나 줄바꿈으로만 나뉜다
it('구분 없이 이어 붙은 요일별 구간을 나눠 읽는다(더현대 대구)', () => {
const p = parseOpeningHours(
'월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30',
);
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
expect(p.week[SAT]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
expect(isOpenAt(p, MON, at(20, 15))).toBe(false);
expect(isOpenAt(p, SAT, at(20, 15))).toBe(true);
});
it('"평일 …주말 …" 처럼 붙은 구간도 나눈다', () => {
const p = parseOpeningHours('평일 09:00~18:00주말 09:00~17:00');
expect(isOpenAt(p, MON, at(17, 30))).toBe(true);
expect(isOpenAt(p, SAT, at(17, 30))).toBe(false);
});
it('줄바꿈은 구간 구분으로 본다', () => {
const p = parseOpeningHours(
'월요일~목요일 10:30~20:00\n금요일~일요일 10:30~20:30',
);
expect(p.week[FRI]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
expect(p.week[WED]).toEqual([{ start: at(10, 30), end: at(20) }]);
});
it('줄을 바꿔 쓴 괄호 안 준비 시간도 준비 시간으로 읽는다', () => {
const p = parseOpeningHours('11:00~21:00\n(준비 시간 15:30~17:00)');
expect(p.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, WED, at(12))).toBe(true);
});
it('대괄호 요일 머리말은 다음 줄들의 시간에 적용한다(곳 카페)', () => {
const p = parseOpeningHours(
'[평일 / 일요일]\n- 11:00~17:30\n[토요일]\n- 11:00~19:00',
);
expect(p.week[MON]).toEqual([{ start: at(11), end: at(17, 30) }]);
expect(p.week[SUN]).toEqual([{ start: at(11), end: at(17, 30) }]);
expect(p.week[SAT]).toEqual([{ start: at(11), end: at(19) }]);
});
it('괄호 안의 / 로 구간을 자르지 않고, 준비 시간·마지막 주문을 각각 읽는다(갈비만)', () => {
const p = parseOpeningHours(
'주중 11:30 ~ 22:30 (브레이크타임 14:30~16:30 / 라스트오더 21:30)',
);
expect(p.week[MON]).toEqual([{ start: at(11, 30), end: at(22, 30) }]);
expect(p.breaks).toEqual([{ start: at(14, 30), end: at(16, 30) }]);
expect(p.lastOrder).toBe(at(21, 30));
});
it('주중 줄의 브레이크타임은 주말에 적용하지 않는다(갈비만 전체 원문)', () => {
const p = parseOpeningHours(
'주중 11:30 ~ 22:30 (브레이크타임 14:30~16:30 / 라스트오더 21:40)\n주말 11:30 ~ 22:30 (브레이크타임 없음 / 라스트오더 21:40)',
'연중무휴',
);
expect(p.breaksWeekdaysOnly).toBe(true);
expect(isOpenAt(p, WED, at(15))).toBe(false);
expect(isOpenAt(p, SAT, at(15))).toBe(true);
expect(p.lastOrder).toBe(at(21, 40));
});
it('달 머리말("[1월]")은 월요일로 읽지 않고, 달마다 다르면 시각 판단을 하지 않는다', () => {
const p = parseOpeningHours('[1월]\n- 10:30~18:30\n[2월]\n- 10:30~19:00');
expect(p.seasonal).toBe(true);
expect(isOpenAt(p, MON, at(12))).toBeNull();
});
it('하이픈으로 쓴 시간 범위는 나누지 않는다', () => {
const p = parseOpeningHours('10:30-20:00');
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
});
it('매월 몇째 주 휴무는 요일을 막지 않고 안내로만 남긴다', () => {
const p = parseOpeningHours('10:00~20:00', '매월 둘째·넷째 주 월요일');
expect(p.closedWeekdays).toEqual([]);
expect(p.notes[0]).toContain('매월');
});
});
+294
View File
@@ -0,0 +1,294 @@
/**
* 영업시간 문장 파서 (순수 함수).
*
* 관광공사 TourAPI 의 영업시간·쉬는날 필드(usetime, opentimefood, restdatefood 등)는
* 자유 문장이다. 예: "11:00~21:00 (준비 시간 15:30~17:00)", "평일 11:00~22:00 / 주말 10:00~22:00",
* "상시 개방", "점포별 상이", 쉬는날 "매주 화요일", "설·추석 당일".
* AI 코스 검증("도착 시각에 영업 중인가")에 쓰도록 요일별 시간표로 바꾼다.
* 읽을 수 없는 값은 지어내지 않고 'unknown'·'varies' 로 남긴다 — 화면에서는 '영업시간 미확인'으로 보여 준다.
*/
/** 분 단위 시간 구간(00:00 기준). 자정을 넘기면 end 가 1440 보다 크다 */
export interface TimeRange {
start: number;
end: number;
}
/**
* - parsed: 시간표를 읽었다
* - always_open: 상시 개방·24시간
* - varies: 점포별 상이 등 정할 수 없다
* - unknown: 값이 없거나 읽지 못했다
*/
export type OpeningHoursStatus =
| 'parsed'
| 'always_open'
| 'varies'
| 'unknown';
export interface ParsedOpeningHours {
status: OpeningHoursStatus;
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
week: (TimeRange[] | null)[];
/** 준비시간(브레이크 타임) */
breaks: TimeRange[];
/** "준비시간(평일)" 처럼 평일에만 적용되는 브레이크인지 */
breaksWeekdaysOnly: boolean;
/** 마지막 주문 시각(분). 자정을 넘기면 1440 보다 크다 */
lastOrder: number | null;
/** 정기 휴무 요일(0=일요일) */
closedWeekdays: number[];
/** 명절·공휴일 휴무 표시(날짜 계산은 하지 않는다) */
holidayClosures: string[];
/** 하절기·동절기처럼 계절마다 다르다 — 어느 계절인지 알 수 없어 시각 판단을 하지 않는다 */
seasonal: boolean;
/** 시간 외 안내 문구(조기 마감, 방문 전 연락 등) */
notes: string[];
}
const DAY_CHARS = '일월화수목금토';
const MINUTES_PER_DAY = 1440;
/** 문서 앞에 붙는 보이지 않는 문자(BOM). 소스에 직접 쓰지 않으려고 코드값으로 만든다 */
const BOM = String.fromCharCode(0xfeff);
const TIME_RANGE =
/(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)\s*~\s*(익일\s*)?(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/g;
const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/;
/**
* 구분 없이 이어 붙은 구간 — 시각 바로 뒤(공백 없이)에 요일·평일·주말·'['가 오는 곳.
* 2026-11-03 대구 관광공사 원문 33곳: "월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30", "평일 09:00~18:00주말 09:00~17:00"
*/
const SEGMENT_AFTER_TIME =
/(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g;
/** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */
function protectInnerSlashes(text: string): string {
return text
.replace(/\(([^()]*)\)/g, (_, s: string) => `(${s.replace(/\//g, ';')})`)
.replace(/\[([^[\]]*)\]/g, (_, s: string) => `[${s.replace(/\//g, ';')}]`);
}
/** 줄바꿈 태그·줄바꿈·글머리표·물결표 변형을 하나로 맞추고, 구간 사이에 ' / ' 를 넣는다 */
function normalize(text: string): string {
return (
protectInnerSlashes(text)
.replaceAll(BOM, '')
.replace(/<\/?br\s*\/?>/gi, ' / ')
// 줄바꿈은 구간 구분이다(동기화가 <br> 을 줄바꿈으로 바꿔 저장한다). 공백을 합치기 전에 바꾼다
.replace(/\s*\n+\s*/g, ' / ')
.replace(/[∼~〜]/g, '~')
.replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2')
.replace(SEGMENT_AFTER_TIME, '$1 / ')
.replace(/(^|\/)\s*-\s*/g, '$1')
.replace(/\s+/g, ' ')
.trim()
);
}
function toMinutes(hour: string, minute?: string, minuteAlt?: string): number {
return Number(hour) * 60 + Number(minute ?? minuteAlt ?? 0);
}
function parseRanges(segment: string): TimeRange[] {
const ranges: TimeRange[] = [];
for (const m of segment.matchAll(TIME_RANGE)) {
const start = toMinutes(m[1], m[2], m[3]);
let end = toMinutes(m[5], m[6], m[7]);
// 닫는 시각이 여는 시각보다 이르거나 '익일'이면 다음 날로 넘긴다
if (m[4] || end <= start) end += MINUTES_PER_DAY;
ranges.push({ start, end });
}
return ranges;
}
/** 시간 앞에 붙은 요일 표현을 요일 번호 목록으로 바꾼다. 요일 표현이 없으면 null(매일) */
function parseDays(prefix: string): number[] | null {
if (/매일|연중/.test(prefix)) return null;
const days = new Set<number>();
if (/평일|주중/.test(prefix)) [1, 2, 3, 4, 5].forEach((d) => days.add(d));
if (/주말/.test(prefix)) [0, 6].forEach((d) => days.add(d));
const rest = prefix.replace(/평일|주중|주말|공휴일|휴일/g, ' ');
for (const m of rest.matchAll(
/([일월화수목금토])(?:요일)?\s*~\s*([일월화수목금토])(?:요일)?/g,
)) {
let d = DAY_CHARS.indexOf(m[1]);
const to = DAY_CHARS.indexOf(m[2]);
days.add(d);
while (d !== to) {
d = (d + 1) % 7;
days.add(d);
}
}
const withoutRanges = rest.replace(
/[일월화수목금토](?:요일)?\s*~\s*[일월화수목금토](?:요일)?/g,
' ',
);
for (const m of withoutRanges.matchAll(
/([일월화수목금토])(?:요일)?(?=[\s·,;/)\]]|$)/g,
)) {
days.add(DAY_CHARS.indexOf(m[1]));
}
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
}
/** 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다 */
function parseRestDays(text: string, out: ParsedOpeningHours): void {
if (!text || /연중\s*무휴/.test(text)) return;
// '시설 점검' 같은 말에 걸리지 않게 명절 '설'만 잡는다
if (/설(?:날|\s*[·,]|\s*연휴|\s*당일|$)/.test(text))
out.holidayClosures.push('설');
if (/추석/.test(text)) out.holidayClosures.push('추석');
if (/1월\s*1일|신정/.test(text)) out.holidayClosures.push('1월 1일');
if (/공휴일/.test(text)) out.holidayClosures.push('공휴일');
// "매월 둘째·넷째 일요일" 처럼 매주가 아닌 휴무는 요일을 막지 않고 안내로만 남긴다
if (/매월|째\s*주|째/.test(text)) {
out.notes.push(`휴무: ${text}`);
return;
}
for (const m of text.matchAll(/([일월화수목금토])요일/g)) {
const d = DAY_CHARS.indexOf(m[1]);
if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d);
}
out.closedWeekdays.sort((a, b) => a - b);
}
function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours {
return {
status,
week: Array.from({ length: 7 }, () => null),
breaks: [],
breaksWeekdaysOnly: false,
lastOrder: null,
closedWeekdays: [],
holidayClosures: [],
seasonal: false,
notes: [],
};
}
/**
* 영업시간·쉬는날 문장을 요일별 시간표로 바꾼다.
* @param hours 영업시간 원문(예: opentimefood)
* @param restDays 쉬는날 원문(예: restdatefood)
*/
export function parseOpeningHours(
hours: string | null | undefined,
restDays?: string | null,
): ParsedOpeningHours {
const text = normalize(hours ?? '');
const rest = normalize(restDays ?? '');
const hasTime = TIME_RANGE.test(text);
TIME_RANGE.lastIndex = 0;
let result: ParsedOpeningHours;
if (!text) {
result = emptyResult('unknown');
} else if (!hasTime && /상시|24\s*시간|항시/.test(text)) {
result = emptyResult('always_open');
result.week = result.week.map(() => [{ start: 0, end: MINUTES_PER_DAY }]);
} else if (!hasTime) {
result = emptyResult(
/상이|다름|다르|문의/.test(text) ? 'varies' : 'unknown',
);
} else {
result = emptyResult('parsed');
// 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다
result.seasonal =
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text);
const general: TimeRange[] = [];
const specific: { days: number[]; ranges: TimeRange[] }[] = [];
// "[토요일]" 처럼 대괄호 요일 머리말만 있는 줄 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
let headerDays: number[] | null = null;
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
const segment = raw.trim();
if (!segment) continue;
const header = /^\[([^\]]*)\]$/.exec(segment);
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
// 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
continue;
}
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다)
const inner = [...segment.matchAll(/\(([^)]*)\)/g)].flatMap((m) =>
m[1].split(/\s*;\s*/),
);
const body = segment.replace(/\([^)]*\)/g, ' ');
for (const part of [body, ...inner]) {
if (/준비\s*시간|브레이크|휴식\s*시간|break/i.test(part)) {
result.breaks.push(...parseRanges(part));
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
const weekdaysOnly =
/평일|주중/.test(part) ||
/^(평일|주중)/.test(segment) ||
(part === body && /\(\s*평일\s*\)/.test(segment));
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
continue;
}
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
const m = SINGLE_TIME.exec(part);
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
continue;
}
const closedInHours =
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
if (closedInHours) parseRestDays(part, result);
const ranges = parseRanges(part);
if (ranges.length === 0) {
if (part !== body || !closedInHours) result.notes.push(part.trim());
continue;
}
const prefix = part.slice(0, part.search(/\d/));
const days = parseDays(prefix) ?? headerDays;
if (days) specific.push({ days, ranges });
else general.push(...ranges);
}
}
// 매일 적용되는 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다
if (general.length > 0) result.week = result.week.map(() => [...general]);
for (const { days, ranges } of specific) {
for (const d of days) result.week[d] = [...ranges];
}
result.notes = result.notes.filter((n) => n.length > 0);
}
parseRestDays(rest, result);
for (const d of result.closedWeekdays) result.week[d] = [];
return result;
}
/**
* 해당 요일·시각에 영업 중인지 판단한다.
* @param weekday 0=일요일 … 6=토요일
* @param minutes 00:00 부터의 분
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
*/
export function isOpenAt(
parsed: ParsedOpeningHours,
weekday: number,
minutes: number,
): boolean | null {
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
if (parsed.seasonal) return null;
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
const yesterday = parsed.week[(weekday + 6) % 7];
const fromYesterday = (yesterday ?? []).some(
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
);
if (fromYesterday) return true;
if (parsed.closedWeekdays.includes(weekday)) return false;
const inBreak = parsed.breaks.some(
(b) => minutes >= b.start && minutes < b.end,
);
const breakApplies =
!parsed.breaksWeekdaysOnly || (weekday >= 1 && weekday <= 5);
if (inBreak && breakApplies) return false;
const today = parsed.week[weekday];
if (today === null) return null;
return today.some((r) => minutes >= r.start && minutes < r.end);
}
@@ -0,0 +1,22 @@
import { toSnakeCase } from './snake-case';
describe('toSnakeCase', () => {
it('camelCase 속성 이름을 snake_case 컬럼 이름으로 바꾼다', () => {
expect(toSnakeCase('sourceId')).toBe('source_id');
expect(toSnakeCase('ldongRegnCd')).toBe('ldong_regn_cd');
expect(toSnakeCase('publicHoursText')).toBe('public_hours_text');
});
it('PascalCase 클래스 이름을 테이블 이름으로 바꾼다(숫자 포함)', () => {
expect(toSnakeCase('PlaceI18n')).toBe('place_i18n');
expect(toSnakeCase('PlaceSyncRun')).toBe('place_sync_run');
});
it('대문자 약어가 이어져도 단어 경계를 지킨다', () => {
expect(toSnakeCase('HTTPServer')).toBe('http_server');
});
it('이미 snake_case 인 이름은 그대로 둔다', () => {
expect(toSnakeCase('place_id')).toBe('place_id');
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* camelCase·PascalCase 식별자를 snake_case 로 바꾼다 (DB 테이블·컬럼 이름 규칙용).
* 예: sourceId → source_id, PlaceI18n → place_i18n, ldongRegnCd → ldong_regn_cd
*/
export function toSnakeCase(name: string): string {
return (
name
// 소문자·숫자 뒤의 대문자 앞에 밑줄: sourceId → source_Id
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
// 대문자 연속 뒤에 단어가 시작될 때: HTTPServer → HTTP_Server
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
);
}
+11
View File
@@ -0,0 +1,11 @@
import { escapeLike } from './sql-like';
describe('escapeLike', () => {
it('와일드카드와 이스케이프 문자를 글자 그대로 찾게 한다', () => {
expect(escapeLike('100%_카페\\')).toBe('100\\%\\_카페\\\\');
});
it('일반 글자는 그대로 둔다', () => {
expect(escapeLike('김광석 길')).toBe('김광석 길');
});
});
+7
View File
@@ -0,0 +1,7 @@
/**
* LIKE/ILIKE 패턴에 넣을 사용자 입력을 글자 그대로 찾게 한다 (순수 함수).
* `%`·`_`(와일드카드)와 `\`(PostgreSQL 기본 이스케이프 문자)를 이스케이프한다. 값은 반드시 바인딩 파라미터로 넘긴다.
*/
export function escapeLike(input: string): string {
return input.replace(/[\\%_]/g, (c) => `\\${c}`);
}

Some files were not shown because too many files have changed in this diff Show More