40 Commits

Author SHA1 Message Date
ttipo 7ed3411abb fix(backend): 빈 DB 에서 코스 생성 실행기의 기동 정리가 e2e 를 멈추게 하던 문제 수정
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m7s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m54s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m31s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m47s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m20s
CI / Frontend (lint · type-check · test) (push) Successful in 11m31s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m46s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m53s
CI 의 빈 DB 에서 e2e 가 앱을 띄운 뒤에 마이그레이션을 돌려, 실행기의 기동 정리가
아직 없는 테이블을 찾다가 기동이 실패했다. 앱을 닫지 못해 1분 주기 정리 작업이 남아
jest 가 끝나지 않았고 CI 가 러너 제한 시간까지 멈춰 있었다.

- 기동 정리는 실패해도 기동을 막지 않고 error 로그만 남긴다
- 앱 전체를 띄우는 e2e 는 운영처럼 마이그레이션을 먼저 돌린다
- 준비가 실패해도 afterAll 에서 앱을 반드시 닫는다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:01:26 +09:00
ttipo 09c8decb15 feat(backend): AI 코스 생성 API 골격과 생성 기록 테이블 추가
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m59s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
CI / Backend (lint · test · e2e · build) (pull_request) Has been cancelled
설계 4단계 — 키 없이 생성 흐름을 끝까지 돌려 볼 수 있게 API·테이블·에러 코드를
먼저 세우고, AI 자리는 가짜 클라이언트로 채웠다(5단계에서 실제 모델로 바꾼다).

- 조건 해석·생성 시작·진행 조회·취소·평가·오늘 남은 횟수 API 6개
- ai_course_generation 테이블(마이그레이션)과 에러 코드 AI_001~004
- 기기 단위 하루 횟수(결과가 나온 생성만 차감)·시도 상한·하루 비용 상한·동시 생성 막기
- 프로세스 안 실행기: 동시 5건, 작업당 45초, 취소 시 AI 호출 중단,
  기동·종료 때 끝나지 못한 작업은 실패(차감 없음)로 정리
- 후보 찾기 → 거르기·줄이기 → AI 초안 → 시간표 검증·고치기 → 결과 조립
- 요청 문장의 연락처는 저장 전에 가리고, 현재 위치는 약 100m 단위로만 남긴다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:37:26 +09:00
ttipo 187a99ca7f feat(backend): 기상청 단기예보와 공휴일 표 추가
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m5s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m31s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m9s
CI / Frontend (lint · type-check · test) (push) Successful in 11m12s
CI / Backend (lint · test · e2e · build) (push) Successful in 11m46s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m8s
AI 코스 조건 해석·거르기(설계 §3.3)에 그날 날씨와 공휴일이 필요하다. 강수확률 40% 이상이면 실내 위주로 짜고,
공휴일·명절에 쉬는 곳은 후보에서 뺀다. 4단계(API 골격)에서 생성 작업에 연결한다.

- 공휴일 표(2026–2027): 설·추석 연휴·대체공휴일·지방선거일, 2026년부터 공휴일이 된 노동절·제헌절(대체공휴일 포함).
  대체공휴일 규칙을 테스트로 검사해 해마다 표를 갱신할 때 빠뜨린 날을 잡는다
- 공휴일 거르기를 명절 종류에 맞췄다 — 설·추석 당일 휴관인 곳을 어린이날에 빼지 않고, '공휴일' 휴관은 모든 공휴일에 뺀다
- 날씨: 위경도 → 5km 격자, 가장 최근 발표(자료가 아직 없으면 앞 발표), 시간 범위의 최고 강수확률·강수 형태·하늘·기온.
  1시간 캐시, 3초·2번 시도, 하루 1,000번, 실패하면 null(입력 화면은 '예보 없음')
- 공공데이터 공통 클라이언트에 호출 제한 시간·시도 횟수 설정을 더했다(기존 동기화는 그대로 20초·3번)
- 실제 호출 확인 명령 weather:check

2026-11-08 실제 호출: 대구 도심 격자 89,90, 14시 발표로 11-08~11-11 예보, 11-12 는 예보 없음(호출 1번).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:39:58 +09:00
ttipo d2cd5cd96e feat(backend): AI 코스 시간표를 짜고 검증·고치는 순수 로직 추가
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 13m30s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m28s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m56s
CI / Backend (lint · test · e2e · build) (push) Successful in 14m17s
CI / Frontend (lint · type-check · test) (push) Successful in 12m17s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 12m55s
AI 코스 생성(설계 §3.3–§3.7)에서 코드가 맡는 부분이다. AI 는 후보 번호로 장소와 머무는 시간만 고르고,
영업·이동·반려동물 조건 검증과 순서 고치기는 코드가 해야 닫힌 곳으로 보내거나 지어낸 사실이 화면에 나가지 않는다.
API·AI 연동(4·5단계) 전에 키 없이 시험할 수 있게 DB·AI 와 떨어진 순수 함수로 먼저 만든다.

- 거르기: 반경·행사 기간·공휴일 휴무·반려동물·그날 영업(최소 머무는 시간만큼)
- 줄이기: 장소와 행사를 합쳐 40곳(행사 8개까지, 분류마다 10곳), 다시 만들기면 이전 장소를 빼거나 뒤로
- 시간표·검증: 직선거리 이동 추정, 영업·마지막 주문·끝 시각·구간 이동·반려동물
- 고치기: 순서와 머무는 시간을 함께 찾고(AI 순서와 가까운 것 우선), 안 되면 바꾸기 후보로 바꾸거나 뺀다
- 비 조건 실내 비율, 바꾸기 후보를 같은 시간대로 검증, 장소별 경고

6곳 코스 고치기는 대부분 20ms 안이고 가장 나쁜 경우 약 50ms 였다(서버가 한 대라 따지는 순서를 5,000개로 제한).
구현하며 정한 기준은 설계 §3.4·§3.6·§3.7 에 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:49:16 +09:00
ttipo f752bf28f5 fix(backend): 계절별 영업시간을 날짜의 달로 골라 영업 중 표시를 바로잡음
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m4s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 14m24s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m14s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m25s
CI / Frontend (lint · type-check · test) (push) Successful in 11m3s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m25s
하절기·동절기처럼 계절마다 시간표가 다른 곳을 어느 계절인지 모른 채 읽고 있었다.
23곳은 영업 중인지 판단하지 못해 '영업시간 미확인'으로 나왔고(대구미술관·수창청춘맨숀·향촌문화관 등),
7곳은 두 계절 시간을 합쳐 읽어 닫혀 있는 시각에도 영업 중으로 보였다
(대구수목원 9~4월 아침 8시, 달성 한일우호관 11~2월 17시, 상주식당 12~3월 휴업 등).
영업시간 미확인은 AI 코스가 문화시설·식당·카페를 후보에서 빼는 기준이기도 하다(설계 §3.3).

시간표를 계절별로 따로 두고(seasons) 그날 날짜의 달에 맞는 것을 고른다. isOpenAt 은 요일 대신
날짜를 받는다. 시기별 쉬는 요일(restSeasons)과 문을 열지 않는 기간(closedPeriods)도 따로 읽는다.
달 없이 "하절기/동절기"만 적은 곳은 하절기 4~9월·동절기 12~2월만 확실하다고 보고, 3·10·11월에는
두 시간표의 답이 같을 때만 판단한다. 대구 원문의 계절 범위가 기관마다 3~10월·4~11월 등으로 달라서다.

개발 DB 1,117곳을 이전 파서와 비교(2026년 달마다 7일 × 30분)해 30곳의 판단이 바뀌었다.
닫힘에서 영업 중으로, 판단에서 미확인으로 바뀐 곳은 없고, 계절과 상관없는 곳의 해석도 그대로다.
이전 형식으로 저장된 계절 구분 값은 reparse-hours 로 다시 해석하기 전까지 지금처럼 미확인이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:12:29 +09:00
ttipo 542732257c docs: AI 평가셋 검토 결과를 적고 확정함
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m24s
CI / Frontend (lint · type-check · test) (push) Successful in 11m26s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 12m36s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m15s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m51s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m12s
반려동물·경계 사례를 검토하며 정한 정책을 평가셋 README 와 설계 §9 에 남긴다.
요청의 핵심을 맞출 후보가 없으면 다른 곳으로 채우지 않고 만들지 않으며, 일부만 못 맞추면 맞출 수 있는 만큼
만들고 알린다. '돈 안 쓰는 코스'는 식당·카페를 뺀다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:37:29 +09:00
ttipo a15bc9982b docs: AI 평가셋을 대구판(v2)으로 옮김
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m17s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
v1 은 62개 중 60개가 서울·부산·제주 등 대구 밖 지역이라 장소 DB(대구)로는 돌릴 수 없었다.
지역을 대구 동네(도심·수성못·앞산)와 대표 장소로, 날짜를 2026-11-05 기준으로 바꿨다.
사례마다 확인한 조건(conditions)과 문장에서 뽑을 항목(inferred)을 적어 조건 해석도 채점한다.

개발 DB 로 사례마다 거른 뒤 후보가 남는지 확인했다. 그때 본 데이터 사정 — 반려 동반이 확인된 실내 장소 0곳,
전통시장은 큐레이션해야 AI 후보, 11월 대구 축제 없음, 계절별 시간표는 미확인 — 을 README 에 적었다.
v1 은 지역을 넓힐 때 다시 쓰려고 남긴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:23:16 +09:00
ttipo 29e9611bd9 docs: AI 코스 생성 설계안 추가
MVP 의 AI 코스 생성(디자인 화면 8–10)을 만들기 전에 흐름·API·비용 보호·평가 방법을 정한다.
DB 가 후보를 고르고 AI 는 후보 번호로만 고르며, 이동 시간·영업·반려 조건 검증과 순서 고치기는 코드가 한다.
화면의 사실 값(영업시간·동반 조건·출처)은 모두 DB 에서 붙여 AI 가 지어낼 수 없게 한다.

§13 의 8가지(하루 횟수·비용 상한·영업시간 미확인·반려 엄격도·이동 시간·공연 제외·보관 기간·모델)는
2026-11-05 사용자가 추천안대로 정했다. 대구 평가셋을 만들며 찾은 것도 반영했다 — 조건 해석 issues 3개,
별칭이 가리키는 장소를 중심으로, 전시는 실내, 실내 후보가 모자라면 알림으로, 코스를 못 만들면 이유를 함께.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:23:16 +09:00
ttipo 57f332a731 fix(backend): 쉬는날 문구를 잘못 읽어 영업 중 표시가 틀리던 것 수정
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m37s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 12m11s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m39s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m42s
CI / Frontend (lint · type-check · test) (push) Successful in 11m2s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m25s
관광공사 쉬는날(restdate) 원문에서 'X요일' 낱말만 골라 읽어서, 영업 중 표시가 틀린 장소가 17곳 있었다
(2026-11-05 관광공사 상세 26곳을 받은 뒤 점검).
- "공휴일이면 다음 첫 번째 평일 휴관" 의 '번째' 가 '매월 둘째 일요일' 규칙에 걸려 매주 월요일 휴무까지 빠졌다
  (국립대구박물관 등 4곳이 월요일에 영업 중으로 보였다)
- "월요일~목요일" 은 양 끝 요일만 읽고, "주말"·"평일" 은 아예 읽지 않았다(8곳)
- "일요일을 제외한 공휴일", "월요일이 공휴일인 경우 화요일 휴무" 처럼 조건 속 요일을 정기 휴무로 읽었다(5곳)

쉬는날 원문을 '/'·'※'·괄호 단위 구절로 나눠, 조건·예외 문구는 버리고 요일 범위·평일·주말까지 읽는다.
매주가 아닌 휴무(매월·격주·N주차 등)는 구절마다 판단해 안내로만 남기고, 시설별 쉬는날은 모든 시설이
쉬는 요일만 휴무로 둔다. 시기별 쉬는날은 어느 시기인지 몰라 계절 구분으로 두고 영업 중 판단을 하지 않는다.
영업시간 문장 속 "월요일 휴관" 구절에는 범위·평일을 적용하지 않는다 — 같은 구절의 "월~금 10:00~18:00" 은
여는 요일이다.

reparse-hours 로 22곳이 바뀌었고 모두 원문과 대조했다(위 17곳 + 격주·매달 휴무 4곳 + 시기별 1곳).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:16:50 +09:00
ttipo 43e339a95f fix(flutter): 대구 밖 현재 위치는 대구 도심으로 대신하고 안내함
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m41s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m16s
CI / Frontend (lint · type-check · test) (push) Successful in 11m4s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m8s
현재 위치를 그대로 쓰면 대구 밖(서울 등)에서는 모든 칸이 "근처에 아직 없어요"로 비었다(2026-11-04 회귀 점검).
서비스 지역(대구, 군위 포함 — 백엔드 geo.ts 와 같은 영역) 밖이면 대구 도심을 기준으로 쓰고,
홈에서 "아직 대구 지역만 볼 수 있어요"를 한 번 알린다.
위치를 받지 못해 대신하는 경우도 지금까지는 기준만 조용히 바뀌었는데, 같은 방식으로 알린다.
대신한 이유는 BasePoint.fallback 에 남는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:34:23 +09:00
ttipo 90d41f0e6d fix(backend): KOPIS 에 일정변경으로 다시 등록된 같은 공연을 하나로 합침
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m7s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m52s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m21s
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다. 그래서 같은 공연장·같은 날
같은 공연이 두 번 보였다(2026-11-04 회귀 점검: 고령 남진 콘서트).
- 제목 앞의 "[일정변경]" 은 뗀다(화면에는 바뀐 날짜가 이미 보인다)
- 같은 공연장·같은 기간·같은 제목이면 먼저 등록된 것만 남기고 나머지는 숨긴다

문화포털 전시에 쓰는 "제목 6글자 이상 겹침" 기준은 공연에 쓰지 않는다. 처음 적용했을 때 음악제 이름을 앞에 붙인
같은 날 다른 공연("포항국제음악제, …" 두 공연)을 합쳐 버려서, 제목이 같을 때만 합치게 했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:37:16 +09:00
ttipo 898ef04266 fix(backend): 관광공사 사진 주소를 https 로 맞춤
관광공사 장소 사진 1,059건 중 279건이 http 였다. 같은 서버(tong.visitkorea.or.kr)가 https 로도 주므로
저장할 때 https 로 바꾼다(장소·행사 모두). 앱은 http 사진도 받지만 웹(https 페이지)에서는 혼합 콘텐츠로 막힌다.
확인하지 않은 다른 서버의 주소는 그대로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:37:16 +09:00
ttipo 4f50df00d8 fix(backend): 좌표가 주소와 맞지 않거나 실제 위치가 아닌 관광공사 장소를 받지 않음
관광공사 원본 좌표가 실제 위치가 아닌 장소가 거리·지도에 엉뚱하게 나왔다(2026-11-04 회귀 점검).
- 오천서원: 주소는 대구 수성구인데 좌표는 포항이라 대구 목록에 70km 떨어진 곳으로 나왔다
- 대구올레 팔공산 3·4·5코스: 주소·좌표 모두 운영 사무실(수성구)이라 김광석길에서 '도보 7분'으로 보였다

좌표가 주소(없으면 법정동 코드)의 시·도 밖이면 받지 않는다(geo.ts isInSido 재사용).
시·도 검사로 못 거르는 곳은 확인한 contentid 목록(TOUR_WRONG_LOCATION_IDS)으로 받지 않는다.
이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다. 관광공사 장소 1,174곳에 적용해 이 4곳만 걸리는 것을 확인했고,
개발 DB 는 tour-kor --skip-details 로 반영했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:37:15 +09:00
ttipo d4d99c9127 perf(flutter): 사진을 그릴 크기로 줄여 메모리 사용을 줄임
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m27s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 11m41s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m47s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m8s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m40s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m4s
CI / Frontend (lint · type-check · test) (push) Successful in 10m55s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m7s
공연(KOPIS) 포스터는 목록 썸네일도 원본이라(표본 15개 중앙 0.45MB · 최대 9.2MB) 그대로 풀면
사진 한 장에 수십 MB 를 쓴다. 그릴 크기 × 화면 배율로 가로를 정해 줄여 푼다(memCacheWidth).
cover 는 가로가 긴 사진도 세로를 채우도록 세로 × 2 까지 가로를 두고, 원본보다 크게 늘리지는 않는다.
내려받는 양은 그대로다(원본만 제공된다).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:00:14 +09:00
ttipo c6017fd0ed fix(flutter): 화면 읽기 프로그램으로 누를 수 없던 버튼 6종 수정
Semantics(excludeSemantics: true) 로 감싼 버튼은 안쪽 InkWell 의 누르기 동작까지 지워져
TalkBack·VoiceOver 로 누를 수 없었다(하단 탭 · 언어 · 홈 검색창 · 카테고리 · 기준 위치 · 반려견 칩).
Semantics 에도 같은 onTap 을 준다. 기기 접근성 트리에서 누를 수 있음으로 바뀐 것을 확인했고,
누르기 동작이 빠지면 위젯 테스트가 실패한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:00:14 +09:00
ttipo 5caed15482 fix(flutter): 함께 가기 좋은 곳을 서버 trusted 조건으로 받아 도심에서도 채움
출처와 상관없이 가까운 30곳을 받아 관광공사·확인 장소만 고르던 것을 서버 조건(trusted)으로 바꿨다.
도심에서는 받은 30곳이 모두 상가라 칸이 비었다(봉산서원 0곳 → 6곳, 김광석길 2곳 → 6곳).
자기 자신이 섞여 오므로 보일 수(6)보다 하나 더 받는다.

백엔드 trusted 조건(fix/place-hours-and-nearby)이 먼저 배포되어야 한다 — 모르는 조건은 400 이다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:00:14 +09:00
ttipo 4bd2a9b4ad feat(backend): 장소 목록에 관광공사·확인 장소만 받는 trusted 조건 추가
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 11m54s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m0s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m5s
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
장소 상세 '함께 가기 좋은 곳'은 확인 안 된 상가정보 매장을 빼야 한다(2026-11-02 합의).
앱이 가까운 30곳을 받아 거르면 도심처럼 상가가 빽빽한 곳은 받은 곳이 모두 상가라 칸이 비었다.
관광공사 장소 상세 860곳 중 30곳은 칸이 사라지고 186곳은 덜 보였다(봉산서원은 1.5km 안에
관광공사 장소가 53곳인데 0곳).

trusted=true 면 관광공사 장소와 운영자가 확인한 장소만 주고 '추천 안 함' 판정도 뺀다.
선택 조건이라 기존 호출은 그대로다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:58:27 +09:00
ttipo 785afcc53f fix(backend): 행사 목록에서 끝을 생략한 없는 시작일이 500 이 나던 것 수정
from=2030-13-01 처럼 없는 시작일에 to 를 생략하면 끝 날짜를 계산하다 RangeError 가 나서
VAL_001(400) 대신 SYS_001(500)이 나갔다. from=9999-12-31 은 DB 가 읽지 못하는 끝 날짜를 만들었다.
날짜 계산을 shared/utils/iso-date.ts 순수 함수로 옮기고, 없는 날짜와 네 자리 연도를 넘는 결과는
null 로 알려 VAL_001 로 응답한다. 앱은 항상 두 날짜를 보내므로 사용자에게는 보이지 않았다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:58:27 +09:00
ttipo ff56d78022 fix(backend): 줄바꿈 없이 붙은 영업시간 원문을 구간마다 나눠 읽음
관광공사 원문 107곳이 "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20" 처럼
줄바꿈 없이 붙어 온다. 맨 앞 글머리표만 지우던 파서는 이를 한 구간으로 보고 영업시간까지
준비시간·마지막 주문으로 읽었다. 그래서 39곳이 영업 중인 낮에 '영업 종료'로 보였고
41곳은 요일 시간이 통째로 비었다(2026-11-04 회귀 점검).

- 시각·닫는 괄호 뒤의 '- '(다음이 숫자가 아닌 말)와 대괄호 머리말 앞에서 구간을 나눈다
- 시설 머리말("[칠성시장] … [칠성야시장] …")마다 시간표를 따로 만들고 요일별로 합친다.
  나누기만 하면 뒤 시설의 요일 시간이 앞 시설 시간을 지워 일부 시간대가 빠졌다
- '휴게시간'은 쉬는 시간, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분)
- 요일 사이의 '/' 는 나열로 읽고, 0분 구간("13:00~13:00")은 24시간으로 읽지 않고 버린다

개발 DB 는 reparse-hours 로 다시 해석했다(107곳 바뀜, 공공데이터 API 재호출 없음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:58:27 +09:00
ttipo 377181eade fix(flutter): 홈 축제·행사 반경을 120km 에서 50km 로 줄임
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 11m11s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m27s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m44s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m26s
CI / Backend (lint · test · e2e · build) (push) Successful in 11m56s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m16s
CI / Frontend (lint · type-check · test) (push) Successful in 10m49s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 10m29s
120km 는 관광공사 행사뿐이던 2026-11-03 에 대구 행사가 너무 적어 경북까지 넓힌 값이다.
KOPIS·문화포털이 들어온 뒤로는 대구 안에서도 충분히 차고(도심 기준 이번 주말 50km 32건 ·
120km 42건), 120km 면 영덕·영주처럼 차로 2시간 거리까지 나왔다.
50km 는 군위를 포함한 대구 전역과 경산·칠곡까지다(2026-11-04 결정).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:27:58 +09:00
ttipo c21fbbd973 fix(flutter): 화면 아래가 내비게이션 바에 가려지고 상세 위가 상태 표시줄과 겹치는 것 수정
실기기(Android 16)에서 확인했다. Android 15 부터 앱이 화면 끝까지 그려지는데,
목록에 여백을 직접 준 화면은 시스템 여백이 빠져 마지막 출처 줄이 내비게이션 바에 가렸다
(장소 상세 · 행사 상세 · 행사 목록).

장소 상세는 사진 위에서 상태 표시줄 글자가 검은색이라 잘 안 보였고, 내리면 본문 제목이
상태 표시줄과 겹쳤다. 사진 위에서는 흰 글자와 위쪽 그늘을 두고, 사진을 지나면 흰 제목 줄
(뒤로 · 장소 이름)을 깐다. 상단 바가 없는 화면의 기본값(검은 글자)은 앱 뿌리에서 정한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:27:58 +09:00
ttipo 4bd142a9b7 feat(flutter): 장소 검색·상세 화면과 홈 검색창·카테고리·반려동물 장소 칸
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
디자인 캔버스 4(검색)·6·7(장소 상세) 중 지금 장소 API 로 되는 부분을 붙였다.

- 검색은 반경 20km(백엔드 최대) 거리순. 대구 도심에서 대구 전역이 들어온다.
- 영업시간을 모르는 곳은 "영업시간 미확인 · 방문 전 확인"으로 보인다.
- 한눈에 보기는 공공데이터 원문으로 확실한 것만 올린다(주차 '인근 공영주차장' 같은 애매한 값은 빼기).
- 함께 가기 좋은 곳에는 확인 안 된 상가정보 매장을 넣지 않는다(2026-11-02 합의).
- 1330 관광통역안내는 영어 화면에만(2026-10-29 합의).

검색의 '축제' 탭과 실내·무료·주차·이번 주말 조건은 데이터가 없어 넣지 않았다
(행사 API 검색어 조건 · 운영자 확인 장소 · 날씨).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:56 +09:00
ttipo 8f711c1706 refactor(flutter): 공공데이터 사진 위젯과 출처 모델을 공용으로 분리
장소 화면도 공공누리 사진과 출처 표기를 써야 해서, 행사 쪽에 있던 것을
shared 로 옮겼다. Type3 사진을 자르지 않는 규칙이 한 위젯(PublicImage)에만 있게 한다.
event_models 는 출처 모델을 다시 내보내 기존 import 는 그대로 동작한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:55 +09:00
ttipo b6abc3d118 chore(ci): 러너 compose 프로젝트 이름을 러너 이름으로 고정
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m21s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m58s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 22m22s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m22s
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
프로젝트 이름을 지정하지 않으면 폴더 이름(ci)이 되어, 같은 PC 에서 다른 저장소의
ci/ 러너와 이름이 겹쳐 그 러너를 덮어쓸 수 있다(2026-11-04 arasystem 러너와 같은 PC 에서 확인).
compose 파일에 name: ${RUNNER_NAME} 을 두어 -p 없이도 겹치지 않게 하고, README 에 이름을 다르게 지으라고 적었다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:53:50 +09:00
ttipo 19f3574d97 chore(flutter): 앱 패키지 ID 를 net.wandergo.app 으로 정하고 Pretendard 글꼴 번들
패키지 ID 는 스토어 등록·네이버 지도 Client ID 등록에 쓰이고 출시 후 바꿀 수 없어
템플릿 값(com.example.tma_app)을 보유 도메인(wandergo.net) 기준으로 정했다(2026-11-04 사용자 결정).
Android applicationId·namespace·MainActivity 경로, iOS 번들 ID 를 바꾸고
출시 대상이 아닌 macOS·Linux·웹은 식별자·이름만 맞췄다.

디자인 캔버스의 Pretendard 는 woff2 라 Flutter 가 읽지 못해, 공식 저장소
(github.com/orioncactus/pretendard v1.3.9)의 정적 OTF 중 디자인이 쓰는 400·500·600·700 만 넣었다.
SIL OFL 1.1 원문을 함께 두고 라이선스 화면에 등록했다(고지 조건).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:53:50 +09:00
ttipo 0f306e71ba feat(flutter): 앱 뼈대와 홈 이번 주말 축제·행사 화면
데이터 기반(장소·행사 조회 API)이 생겨 앱 화면을 붙이기 시작한다.
디자인 캔버스의 1~7번(시작·둘러보기) 중 지금 API 로 바로 되는 것부터 만들었다.

- 앱 뼈대: 디자인 캔버스의 색·글자 크기·선 아이콘을 옮긴 테마, go_router 하단 탭
  (홈·지도·내 코스·내 정보 — 지도·내 코스·내 정보는 자리만), 한/영 문구(arb, 4개 언어 전제)
- 첫 실행: 현재 위치를 쓰거나 대구 동네(도심·수성못·앞산 카페거리)를 고른다.
  위치를 못 쓰면 대구 도심을 기준으로 시작하고 알린다. 좌표는 저장하지 않는다
- 홈 '이번 주말 축제·행사': 기준 위치 반경 120km 거리순, 비면 다가오는 30일 (2026-11-03 합의)
- 행사 전체 목록(커서 페이지)·상세(시간·요금·예매·전화·소개·출처)
- 한국어 문단은 단어 단위 줄바꿈(keepAll) — Flutter 기본은 글자 단위로 끊는다
- 공공누리 Type3 사진은 자르지 않고, 꼬리말에 관광공사·KOPIS·문화포털 출처를 적는다
- 한/영 생성물(lib/l10n/generated)은 flutter pub get 이 만들므로 git 에서 뺀다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:51:02 +09:00
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
226 changed files with 24636 additions and 1471 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
View File
@@ -25,6 +25,11 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
| 화면 이동 | `lib/core/router/` | go_router `routerProvider` · 경로 상수 `AppRoutes` · 하단 탭 틀 `AppShell` |
| 테마·색 | `lib/core/theme/` | `AppColors` · `AppTheme` — 색·글자 크기를 화면에 새로 하드코딩하지 않는다 |
| 한/영 문구 | `lib/l10n/*.arb` | `context.l10n.키`. 생성물 `lib/l10n/generated/` 는 git 제외 — `flutter pub get` 이 만든다 |
| 한국어 줄바꿈 | `lib/core/utils/keep_all.dart` | 한국어 문단은 `keepAll()` 로 단어 단위 줄바꿈 (Flutter 기본은 글자 단위) |
| 공공데이터 사진 | `lib/shared/widgets/public_image.dart` | 공공누리 Type3 은 자르지 않는다. 사진이 없으면 분류별 자리 표시. 그릴 크기로 줄여 푼다(`memCacheWidth`). 출처 모델은 `shared/models/source_models.dart` |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
+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
@@ -22,6 +22,10 @@ PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 운영 계정 승인 후 승인된 한도로 올린다.
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
+4
View File
@@ -20,6 +20,10 @@ PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 운영 계정 승인 후 승인된 한도로 올린다.
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
+1315 -1081
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -27,7 +27,8 @@
"migration:revert": "npm run typeorm -- migration:revert",
"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"
"place:sync:prod": "node dist/commands/place-sync.js",
"weather:check": "ts-node -r tsconfig-paths/register src/commands/weather-check.ts"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
@@ -43,6 +44,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",
@@ -81,7 +83,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
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { CourseGenerationModule } from '@/modules/course-generation/course-generation.module';
import { HealthModule } from '@/modules/health/health.module';
import { PlaceModule } from '@/modules/place/place.module';
import { UploadModule } from '@/modules/upload/upload.module';
@@ -60,6 +61,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
HealthModule,
PlaceModule,
CourseGenerationModule,
UploadModule,
UserModule,
],
+38 -5
View File
@@ -5,8 +5,11 @@ 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';
@@ -24,7 +27,7 @@ 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)
@@ -37,7 +40,13 @@ const USAGE = `사용법: place:sync <작업> [옵션]
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
--region=27 법정동 시·도 코드 (기본 27 대구)
--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 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
@@ -49,6 +58,8 @@ const JOBS = [
'tour-pet',
'store',
'event',
'kopis',
'kcisa',
'dedupe',
'reparse-hours',
'status',
@@ -61,6 +72,7 @@ function parseArgs(argv: string[]): {
skipDetails: boolean;
signgu: string[];
days: number;
regions: string[];
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
@@ -80,23 +92,40 @@ function parseArgs(argv: string[]): {
const days = daysArg
? Number(daysArg.slice('--days='.length))
: DEFAULT_EVENT_LOOKAHEAD_DAYS;
const regionsArg = rest.find((a) => a.startsWith('--regions='));
const regions = regionsArg
? regionsArg
.slice('--regions='.length)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_EVENT_REGIONS;
return {
job,
region,
skipDetails: rest.includes('--skip-details'),
signgu,
days,
regions,
};
}
async function main(): Promise<void> {
const { job, region, skipDetails, signgu, days } = parseArgs(
const { job, region, skipDetails, signgu, days, regions } = parseArgs(
process.argv.slice(2),
);
const validSigngu =
signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v));
const validDays = Number.isInteger(days) && days >= 1 && days <= 365;
if (!job || !/^\d{2}$/.test(region) || !validSigngu || !validDays) {
const validRegions =
regions.length > 0 && regions.every((v) => /^\d{2}$/.test(v));
if (
!job ||
!/^\d{2}$/.test(region) ||
!validSigngu ||
!validDays ||
!validRegions
) {
Logger.error(USAGE, 'PlaceSync');
process.exitCode = 1;
return;
@@ -113,7 +142,11 @@ async function main(): Promise<void> {
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'event') {
await app.get(FestivalSync).run({ regionCode: region, days });
await app.get(FestivalSync).runRegions({ regionCodes: regions, days });
} else if (job === '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') {
@@ -0,0 +1,23 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { WeatherModule } from '@/modules/weather/weather.module';
import { validateEnv } from '@/shared/config/env.validation';
/**
* 날씨 확인 CLI 전용 루트 모듈 — HTTP·DB 없이 설정·캐시·날씨만 올린다.
* 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
*/
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
validate: validateEnv,
}),
CacheModule.register({ isGlobal: true }),
WeatherModule,
],
})
export class WeatherCheckCliModule {}
+113
View File
@@ -0,0 +1,113 @@
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import { WeatherService } from '@/modules/weather/weather.service';
import { toKmaGrid } from '@/modules/weather/kma-grid';
import { winstonConfig } from '@/shared/logger/winston.config';
import { addDaysIso } from '@/shared/utils/iso-date';
import { kstDateString } from '@/shared/utils/kst';
import { WeatherCheckCliModule } from './weather-check.cli.module';
/**
* 기상청 단기예보 실제 호출 확인 (설계: docs/design/ai-course.md §3.3).
* 공공데이터포털 키(PUBLIC_DATA_SERVICE_KEY)를 쓰므로 키가 있는 환경(개발 컨테이너)에서 실행한다. 호출은 1~2번이다.
*
* docker exec wandergo-backend npm run weather:check -- [--lat=35.87021] [--lng=128.59409] [--start=10:00] [--end=20:00]
*/
const USAGE = `사용법: weather:check [옵션]
오늘부터 4일 뒤까지, 날마다 시간 범위의 강수확률·하늘·기온을 보여 준다(예보가 닿지 않는 날은 '예보 없음')
--lat=35.87021 --lng=128.59409 위치 (기본: 대구 도심)
--start=10:00 --end=20:00 시간 범위 (기본 10:00~20:00)`;
/** 기본 위치 — 대구 도심(앱 동네 'downtown' 의 중심) */
const DEFAULT_POINT = { lat: 35.87021, lng: 128.59409 };
const DEFAULT_START = '10:00';
const DEFAULT_END = '20:00';
/** 오늘부터 며칠 뒤까지 보여 줄지 — 단기예보는 길어야 4일 뒤까지다 */
const DAYS_TO_SHOW = 5;
function option(argv: string[], name: string): string | undefined {
return argv
.find((a) => a.startsWith(`--${name}=`))
?.slice(name.length + 3)
.trim();
}
function toMinutes(hhmm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm);
if (!m) return null;
const minutes = Number(m[1]) * 60 + Number(m[2]);
return minutes <= 1440 ? minutes : null;
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const lat = Number(option(argv, 'lat') ?? DEFAULT_POINT.lat);
const lng = Number(option(argv, 'lng') ?? DEFAULT_POINT.lng);
const start = toMinutes(option(argv, 'start') ?? DEFAULT_START);
const end = toMinutes(option(argv, 'end') ?? DEFAULT_END);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lng) ||
start === null ||
end === null ||
end <= start
) {
Logger.error(USAGE, 'WeatherCheck');
process.exitCode = 1;
return;
}
const app = await NestFactory.createApplicationContext(
WeatherCheckCliModule,
{
logger: WinstonModule.createLogger(winstonConfig),
},
);
try {
const weather = app.get(WeatherService);
const now = new Date();
const grid = toKmaGrid(lat, lng);
Logger.log(`격자 ${grid.nx},${grid.ny}`, 'WeatherCheck');
let found = 0;
for (let day = 0; day < DAYS_TO_SHOW; day++) {
const date = addDaysIso(kstDateString(now), day) ?? kstDateString(now);
const forecast = await weather.forecastWindow(
{ lat, lng },
date,
start,
end,
now,
);
if (!forecast) {
Logger.log(`${date} 예보 없음`, 'WeatherCheck');
continue;
}
found += 1;
Logger.log(
`${date} 강수확률 ${forecast.precipitationProbability}% (${forecast.precipitationType}) · ` +
`하늘 ${forecast.sky ?? '미확인'} · 기온 ${forecast.temperatureMin ?? '?'}~${forecast.temperatureMax ?? '?'}℃ · ` +
`발표 ${forecast.issuedAt}`,
'WeatherCheck',
);
}
if (found === 0) {
// 키 없음·호출 실패면 위 경고 로그에 이유가 있다
Logger.error('예보를 하나도 받지 못했다', 'WeatherCheck');
process.exitCode = 1;
}
} finally {
await app.close();
}
}
main().catch((error: unknown) => {
// 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
Logger.error(
error instanceof Error ? error.message : String(error),
'WeatherCheck',
);
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);
+23
View File
@@ -73,6 +73,29 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// AI 코스 생성 (docs/design/ai-course.md §7)
AI_001: {
// 하루 횟수를 다 썼다(실패·취소를 포함한 시도 상한도 같다)
status: HttpStatus.TOO_MANY_REQUESTS,
message:
'오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.',
},
AI_002: {
// AI 를 쓸 수 없다 — 장애 · 제한 시간 초과 · 하루 비용 상한 · 키 없음
status: HttpStatus.SERVICE_UNAVAILABLE,
message: '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.',
},
AI_003: {
// 조건에 맞는 장소가 부족해 코스를 만들지 못했다(이유는 failure.notices 로 함께 준다)
status: HttpStatus.UNPROCESSABLE_ENTITY,
message:
'조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.',
},
AI_004: {
// 같은 기기·사용자가 이미 만드는 중이다
status: HttpStatus.CONFLICT,
message: '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -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,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* AI 코스 생성 기록(설계 docs/design/ai-course.md §6.1) — 진행 상태·결과 초안·하루 횟수·비용·품질 지표.
* 하루 사용량과 비용은 이 테이블에서 센다(집계 테이블은 따로 두지 않는다).
*/
export class AddAiCourseGeneration1794127708706 implements MigrationInterface {
name = 'AddAiCourseGeneration1794127708706';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "ai_course_generation" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "user_id" uuid, "device_hash" text NOT NULL, "lang" text NOT NULL DEFAULT 'ko', "request_text" text, "conditions" jsonb NOT NULL, "status" text NOT NULL, "stage" text, "found_count" integer, "kept_count" integer, "result" jsonb, "failure_code" text, "failure_notices" jsonb, "counted" boolean NOT NULL DEFAULT false, "previous_id" uuid, "model" text, "prompt_version" text, "input_tokens" integer NOT NULL DEFAULT '0', "output_tokens" integer NOT NULL DEFAULT '0', "cache_read_tokens" integer NOT NULL DEFAULT '0', "cache_write_tokens" integer NOT NULL DEFAULT '0', "cost_usd" double precision NOT NULL DEFAULT '0', "latency_ms" integer, "llm_calls" integer NOT NULL DEFAULT '0', "repairs" jsonb NOT NULL DEFAULT '[]', "rating" text, "rated_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "started_at" TIMESTAMP WITH TIME ZONE, "finished_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "ck_ai_course_generation_rating" CHECK ("rating" IS NULL OR "rating" IN ('like', 'dislike')), CONSTRAINT "ck_ai_course_generation_failure_code" CHECK ("failure_code" IS NULL OR "failure_code" IN ('AI_002', 'AI_003')), CONSTRAINT "ck_ai_course_generation_lang" CHECK ("lang" IN ('ko', 'en')), CONSTRAINT "ck_ai_course_generation_stage" CHECK ("stage" IS NULL OR "stage" IN ('searching', 'filtering', 'planning', 'finishing')), CONSTRAINT "ck_ai_course_generation_status" CHECK ("status" IN ('queued', 'running', 'succeeded', 'failed', 'canceled')), CONSTRAINT "PK_103a7aa94bb0747a58962c4dbf9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_ai_course_generation_status" ON "ai_course_generation" ("status") `,
);
await queryRunner.query(
`CREATE INDEX "ix_ai_course_generation_user" ON "ai_course_generation" ("user_id", "created_at") `,
);
await queryRunner.query(
`CREATE INDEX "ix_ai_course_generation_device" ON "ai_course_generation" ("device_hash", "created_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."ix_ai_course_generation_device"`,
);
await queryRunner.query(
`DROP INDEX "public"."ix_ai_course_generation_user"`,
);
await queryRunner.query(
`DROP INDEX "public"."ix_ai_course_generation_status"`,
);
await queryRunner.query(`DROP TABLE "ai_course_generation"`);
}
}
@@ -0,0 +1,98 @@
/**
* AI 코스 생성이 부르는 AI 창구(설계 §2·§3.5). 4단계는 가짜 AI(FakeCourseAiClient)를 쓰고,
* 5단계에서 Anthropic 클라이언트를 붙인다. 테스트는 계속 가짜를 쓴다.
*/
import type { WindowForecast } from '@/modules/weather/kma-forecast';
import type { CourseLang } from '../course-generation.constants';
import type {
CourseConditions,
ResolvedConditions,
} from '../course-generation.types';
import type {
CourseDraft,
ShortlistEntry,
Violation,
} from '../planner/planner.types';
export const COURSE_AI_CLIENT = Symbol('COURSE_AI_CLIENT');
/** 호출 한 번의 사용량 — 하루 비용 상한과 품질 지표(ai_course_generation)에 쓴다 */
export interface AiUsage {
model: string;
promptVersion: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
costUsd: number;
}
export interface CourseDraftInput {
lang: CourseLang;
/** 사용자가 쓴 문장(전화번호·이메일 모양은 가렸다) */
requestText: string;
resolved: ResolvedConditions;
/** 비 조건(강수확률 40% 이상 + 비 오면 실내) — AI 에 '실내 위주'를 알린다 */
preferIndoor: boolean;
weather: WindowForecast | null;
/** 그날이 공휴일이면 이름 */
holidayName: string | null;
/** 짧은 번호(P01·E01)를 붙인 후보 — AI 는 이 번호로만 고른다 */
shortlist: ShortlistEntry[];
/** 다시 부를 때 붙이는 위반 목록(고치기 4) */
violations: Violation[];
signal: AbortSignal;
}
export interface CourseDraftOutput {
/** 코스 이름(20자 이내) · 요약(120자 이내) */
title: string;
summary: string;
draft: CourseDraft;
usage: AiUsage;
}
/** 조건 해석이 되묻거나 알릴 것(§2) */
export const CONDITION_ISSUES = [
'unsupported_area',
'past_date',
'date_out_of_range',
'multi_day',
'too_short',
'past_midnight',
'unclear_request',
] as const;
export type ConditionIssue = (typeof CONDITION_ISSUES)[number];
export interface ConditionParseInput {
text: string;
lang: CourseLang;
/** 오늘 한국 날짜 'YYYY-MM-DD' 와 지금 시각(분) — AI 에는 날짜·요일만 보낸다(§2) */
today: string;
nowMinutes: number;
signal: AbortSignal;
}
export interface ConditionParseOutput {
/** 문장에서 뽑은 조건 — 없는 항목은 기본값을 쓴다 */
extracted: Partial<CourseConditions>;
inferred: (keyof CourseConditions)[];
issues: ConditionIssue[];
usage: AiUsage;
}
export interface CourseAiClient {
/** 후보 목록에서 장소·순서·머무는 시간·이유를 고른다 */
draftCourse(input: CourseDraftInput): Promise<CourseDraftOutput>;
/** 문장에서 지역·날짜·시간 같은 조건을 뽑는다(작은 모델) */
parseConditions(input: ConditionParseInput): Promise<ConditionParseOutput>;
}
/** AI 를 쓸 수 없음 — 장애 · 제한 시간 초과 · 키 없음 → AI_002 */
export class AiUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'AiUnavailableError';
}
}
@@ -0,0 +1,113 @@
import type { ResolvedConditions } from '../course-generation.types';
import { CENTER, event, place } from '../planner/planner.fixtures';
import type { Candidate, ShortlistEntry } from '../planner/planner.types';
import { FakeCourseAiClient } from './fake-course-ai.client';
function shortlist(...candidates: Candidate[]): ShortlistEntry[] {
return candidates.map((candidate, i) => ({
ref: `P${String(i + 1).padStart(2, '0')}`,
candidate,
distanceM: i * 100,
}));
}
function resolved(start: number, end: number): ResolvedConditions {
return {
conditions: {
area: { type: 'neighborhood', key: 'downtown' },
date: '2026-11-14',
start: '13:00',
end: '19:00',
party: 2,
pet: null,
mobility: 'walk',
indoorIfRain: true,
},
center: CENTER,
placeName: null,
start,
end,
};
}
const input = (list: ShortlistEntry[], start = 13 * 60, end = 19 * 60) => ({
lang: 'ko' as const,
requestText: '',
resolved: resolved(start, end),
preferIndoor: false,
weather: null,
holidayName: null,
shortlist: list,
violations: [],
signal: new AbortController().signal,
});
describe('FakeCourseAiClient.draftCourse', () => {
const ai = new FakeCourseAiClient();
it('1.5시간당 1곳을, 분류가 겹치지 않게 목록 앞쪽부터 고른다', async () => {
const list = shortlist(
place({ category: 'cafe' }),
place({ category: 'cafe' }),
place({ category: 'culture' }),
place({ category: 'nature' }),
place({ category: 'food' }),
place({ category: 'food' }),
);
// 6시간 → 4곳
const out = await ai.draftCourse(input(list));
expect(out.draft.stops.map((s) => s.ref)).toEqual([
'P01',
'P03',
'P04',
'P05',
]);
// 같은 분류의 다음 후보를 바꾸기 후보로 둔다
expect(out.draft.alternates).toEqual([
expect.objectContaining({ forStop: 1, ref: 'P02' }),
expect.objectContaining({ forStop: 4, ref: 'P06' }),
]);
expect(out.usage).toMatchObject({ model: 'fake', costUsd: 0 });
});
it('최소 2곳·최대 6곳이고, 분류가 모자라면 같은 분류로 채운다', async () => {
const cafes = shortlist(
...Array.from({ length: 8 }, () => place({ category: 'cafe' })),
);
const short = await ai.draftCourse(input(cafes, 13 * 60, 15 * 60));
expect(short.draft.stops).toHaveLength(2);
const long = await ai.draftCourse(input(cafes, 10 * 60, 22 * 60));
expect(long.draft.stops).toHaveLength(6);
});
it('머무는 시간은 분류 최소 + 20분이다(행사 60–150 → 80)', async () => {
const out = await ai.draftCourse(
input(shortlist(event(), place({ category: 'cafe' }))),
);
expect(out.draft.stops.map((s) => s.minutes)).toEqual([80, 60]);
});
it('끊긴 호출은 던진다', async () => {
const controller = new AbortController();
controller.abort(new Error('취소'));
await expect(
ai.draftCourse({
...input(shortlist(place())),
signal: controller.signal,
}),
).rejects.toThrow('취소');
});
});
describe('FakeCourseAiClient.parseConditions', () => {
it('문장을 읽지 않고 빈 조건을 돌려준다(기본값만 채운다)', async () => {
const out = await new FakeCourseAiClient().parseConditions({
text: '비 오는 토요일 수성못',
lang: 'ko',
today: '2026-11-08',
nowMinutes: 600,
signal: new AbortController().signal,
});
expect(out).toMatchObject({ extracted: {}, inferred: [], issues: [] });
});
});
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import {
candidateCategory,
diversityKey,
stayRange,
} from '../planner/candidate';
import { STOPS_MAX, STOPS_MIN } from '../planner/planner.constants';
import type {
Candidate,
DraftAlternate,
DraftStop,
ShortlistEntry,
} from '../planner/planner.types';
import type {
AiUsage,
ConditionParseInput,
ConditionParseOutput,
CourseAiClient,
CourseDraftInput,
CourseDraftOutput,
} from './course-ai.client';
export const FAKE_MODEL = 'fake';
export const FAKE_PROMPT_VERSION = 'fake-1';
/** 1.5시간당 1곳(설계 §3.6) */
const MINUTES_PER_STOP = 90;
/** 머무는 시간 — 분류 최소 + 이만큼(범위 안) */
const EXTRA_STAY_MINUTES = 20;
const ZERO_USAGE: AiUsage = {
model: FAKE_MODEL,
promptVersion: FAKE_PROMPT_VERSION,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
costUsd: 0,
};
/** 끊긴 호출이면 그 이유 — 호출부가 늘 거부된 Promise 를 받도록 던지지 않고 돌려준다 */
function abortReason(signal: AbortSignal): Error | null {
if (!signal.aborted) return null;
return signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason));
}
function stayMinutes(c: Candidate): number {
const { min, max } = stayRange(c);
return Math.min(max, min + EXTRA_STAY_MINUTES);
}
/**
* 가짜 AI(개발·테스트용) — 키 없이 생성 흐름을 끝까지 돌린다. 비용은 0 이다.
* 후보 목록 앞쪽(1순위 → 가까운 순)에서 분류가 겹치지 않게 1.5시간당 1곳을 고르고,
* 같은 분류의 다음 후보를 바꾸기 후보로 둔다. 순서·시간 검증과 고치기는 실제 로직(planCourse)이 한다.
* 문장은 읽지 않는다 — 조건 해석은 기본값만 채운다.
*/
@Injectable()
export class FakeCourseAiClient implements CourseAiClient {
draftCourse(input: CourseDraftInput): Promise<CourseDraftOutput> {
const aborted = abortReason(input.signal);
if (aborted) return Promise.reject(aborted);
const { resolved, shortlist, lang } = input;
const target = Math.min(
STOPS_MAX,
Math.max(
STOPS_MIN,
Math.round((resolved.end - resolved.start) / MINUTES_PER_STOP),
),
);
const picked: ShortlistEntry[] = [];
const kinds = new Set<string>();
for (const entry of shortlist) {
if (picked.length >= target) break;
const kind = diversityKey(entry.candidate);
if (kinds.has(kind)) continue;
picked.push(entry);
kinds.add(kind);
}
for (const entry of shortlist) {
if (picked.length >= target) break;
if (!picked.includes(entry)) picked.push(entry);
}
const reason = (e: ShortlistEntry) =>
lang === 'en'
? `Picked by the development fake AI (${candidateCategory(e.candidate)})`
: `개발용 가짜 AI 가 고른 곳(${candidateCategory(e.candidate)})`;
const stops: DraftStop[] = picked.map((e) => ({
ref: e.ref,
minutes: stayMinutes(e.candidate),
reason: reason(e),
}));
const alternates: DraftAlternate[] = [];
picked.forEach((e, i) => {
const alt = shortlist.find(
(other) =>
!picked.includes(other) &&
!alternates.some((a) => a.ref === other.ref) &&
diversityKey(other.candidate) === diversityKey(e.candidate),
);
if (alt) {
alternates.push({ forStop: i + 1, ref: alt.ref, reason: reason(alt) });
}
});
return Promise.resolve({
title: lang === 'en' ? 'Fake AI course' : '가짜 AI 코스',
summary:
lang === 'en'
? 'A development course picked from the top of the candidate list.'
: '개발용 가짜 AI 가 후보 목록 앞쪽에서 고른 코스예요.',
draft: { stops, alternates, notices: [] },
usage: ZERO_USAGE,
});
}
parseConditions(input: ConditionParseInput): Promise<ConditionParseOutput> {
const aborted = abortReason(input.signal);
if (aborted) return Promise.reject(aborted);
return Promise.resolve({
extracted: {},
inferred: [],
issues: [],
usage: ZERO_USAGE,
});
}
}
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { PlaceListing } from '@/modules/place/entities/place-listing.entity';
import { isInSido, type LatLng } from '@/shared/utils/geo';
import { NEIGHBORHOODS, SUPPORTED_SIDO } from './course-generation.constants';
import type { CourseArea } from './course-generation.types';
export interface AreaCenter {
center: LatLng;
/** 지역이 기준 장소면 그 이름 */
placeName: string | null;
}
/**
* 지역 → 중심 좌표(설계 §2). 동네는 앱과 같은 좌표, 현재 위치는 그대로, 기준 장소는 장소 DB 의 좌표다.
* 대구 밖이거나 찾을 수 없는 장소면 VAL_001 — 조건 해석이 먼저 걸러 주므로 앱이 고친 값만 온다.
*/
@Injectable()
export class CourseAreaService {
constructor(
@InjectRepository(PlaceListing)
private readonly listings: Repository<PlaceListing>,
) {}
async resolve(area: CourseArea): Promise<AreaCenter> {
if (area.type === 'neighborhood') {
return { center: NEIGHBORHOODS[area.key], placeName: null };
}
if (area.type === 'point') {
this.assertSupported(area.lat, area.lng);
return { center: { lat: area.lat, lng: area.lng }, placeName: null };
}
const listing = await this.listings.findOneBy({ id: area.placeId });
if (!listing?.isSearchable) {
throw new BusinessException('VAL_001', {
context: { reason: 'area_place_not_found', placeId: area.placeId },
});
}
this.assertSupported(listing.lat, listing.lng);
return {
center: { lat: listing.lat, lng: listing.lng },
placeName: listing.name,
};
}
private assertSupported(lat: number, lng: number): void {
if (!isInSido(SUPPORTED_SIDO, lat, lng)) {
throw new BusinessException('VAL_001', {
context: { reason: 'unsupported_area' },
});
}
}
}
@@ -0,0 +1,186 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
PlaceListing,
type ValueOrigin,
} from '@/modules/place/entities/place-listing.entity';
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
import type {
EventSource,
ImageLicense,
PlaceSource,
} from '@/modules/place/place.constants';
import {
boundingBox,
sqlDistanceM,
sqlEventWithinMaxSpan,
} from '@/modules/place/place.sql';
import type { LatLng } from '@/shared/utils/geo';
import { parseOpeningHours } from '@/shared/utils/opening-hours';
import {
EXCLUDED_EVENT_KINDS,
type Mobility,
SEARCH_RADIUS_M,
} from './planner/planner.constants';
import type { Candidate } from './planner/planner.types';
/** AI 후보 순위(설계 §0) — 1 확인한 곳 · 2 관광공사 장소 */
const AI_TIERS = [1, 2];
/** 출처 표기(장소 상세·행사 상세와 같다) */
const PLACE_PROVIDERS: Record<PlaceSource, string> = {
tour: '한국관광공사',
store: '소상공인시장진흥공단 상가(상권)정보',
};
const EVENT_PROVIDERS: Record<EventSource, string> = {
tour: '한국관광공사',
kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)',
kcisa: '한국문화정보원 문화포털',
dgfca: '대구문화예술진흥원',
};
/** 결과 화면에 붙일 사실 값 — 후보를 고를 때가 아니라 결과를 만들 때 쓴다 */
export interface CandidateFacts {
hoursText: string | null;
hoursOrigin: ValueOrigin;
petOrigin: ValueOrigin;
verifiedAt: string | null;
provider: string;
photo: { thumbUrl: string; license: ImageLicense | null } | null;
eventPeriod: { start: string; end: string } | null;
}
export interface FoundCandidates {
candidates: Candidate[];
/** 후보 id → 사실 값 */
facts: Map<string, CandidateFacts>;
}
function photoOf(
thumbUrl: string | null,
license: ImageLicense | null,
): CandidateFacts['photo'] {
return thumbUrl ? { thumbUrl, license } : null;
}
/**
* 후보 찾기(설계 §3.2) — 이동 수단별 반경 안의
* - 장소: place_listing 에서 노출 중이고 AI 순위(1·2)가 있는 곳. 숙박은 뺀다
* - 행사: 그날 진행하고 숨기지 않았으며 종류별 기간 상한 안인 것. 공연(KOPIS)은 뺀다
* 거르기·줄이기는 순수 로직(planner/filter·shortlist)이 한다.
*/
@Injectable()
export class CourseCandidatesService {
constructor(
@InjectRepository(PlaceListing)
private readonly listings: Repository<PlaceListing>,
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
) {}
async find(
center: LatLng,
date: string,
mobility: Mobility,
): Promise<FoundCandidates> {
const radius = SEARCH_RADIUS_M[mobility];
const box = boundingBox(center.lat, center.lng, radius);
const near = { lat: center.lat, lng: center.lng, radius };
const [places, events] = await Promise.all([
this.listings
.createQueryBuilder('v')
.where('v.is_searchable = true')
.andWhere('v.ai_tier IN (:...tiers)', { tiers: AI_TIERS })
.andWhere('v.category <> :lodging', { lodging: 'lodging' })
.andWhere('v.lat BETWEEN :latMin AND :latMax', box)
.andWhere('v.lng BETWEEN :lngMin AND :lngMax', box)
.andWhere(
`${sqlDistanceM(':lat', ':lng', 'v.lat', 'v.lng')} <= :radius`,
)
.setParameters(near)
.getMany(),
this.events
.createQueryBuilder('e')
.where('e.status = :active', { active: 'active' })
.andWhere('e.start_date <= :date AND e.end_date >= :date', { date })
.andWhere(sqlEventWithinMaxSpan('e'))
.andWhere('e.kind NOT IN (:...excluded)', {
excluded: EXCLUDED_EVENT_KINDS,
})
.andWhere('e.lat BETWEEN :latMin AND :latMax', box)
.andWhere('e.lng BETWEEN :lngMin AND :lngMax', box)
.andWhere(
`${sqlDistanceM(':lat', ':lng', 'e.lat', 'e.lng')} <= :radius`,
)
.setParameters(near)
.getMany(),
]);
const candidates: Candidate[] = [];
const facts = new Map<string, CandidateFacts>();
for (const v of places) {
candidates.push({
kind: 'place',
id: v.id,
name: v.branchName ? `${v.name} ${v.branchName}` : v.name,
category: v.category,
eventKind: null,
lat: v.lat,
lng: v.lng,
tier: v.aiTier,
setting: v.setting,
hours: v.hours,
petPolicy: v.petPolicy,
petSizes: v.petSizes,
startDate: null,
endDate: null,
});
facts.set(v.id, {
hoursText: v.hoursText,
hoursOrigin: v.hoursOrigin,
petOrigin: v.petOrigin,
verifiedAt: v.verifiedAt,
provider: PLACE_PROVIDERS[v.source],
photo: photoOf(v.imageThumbUrl, v.imageLicense),
eventPeriod: null,
});
}
for (const e of events) {
if (e.lat === null || e.lng === null) continue;
// 운영 시간 문구를 영업시간 파서로 읽는다 — 못 읽으면 '행사 시간 확인' 경고와 함께 남긴다(§3.3)
const hours = e.playtimeText ? parseOpeningHours(e.playtimeText) : null;
candidates.push({
kind: 'event',
id: e.id,
name: e.name,
category: 'event',
eventKind: e.kind,
lat: e.lat,
lng: e.lng,
tier: null,
setting: null,
hours,
petPolicy: 'unknown',
petSizes: null,
startDate: e.startDate,
endDate: e.endDate,
});
facts.set(e.id, {
hoursText: e.playtimeText,
hoursOrigin:
hours && hours.status !== 'unknown' && hours.status !== 'varies'
? 'public'
: 'unknown',
petOrigin: 'unknown',
verifiedAt: null,
provider: EVENT_PROVIDERS[e.source],
photo: photoOf(e.imageThumbUrl, e.imageLicense),
eventPeriod: { start: e.startDate, end: e.endDate },
});
}
return { candidates, facts };
}
}
@@ -0,0 +1,98 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { WeatherService } from '@/modules/weather/weather.service';
import { kstClock } from '@/shared/utils/kst';
import { maskContact } from '@/shared/utils/mask-contact';
import {
COURSE_AI_CLIENT,
type ConditionParseOutput,
type CourseAiClient,
} from './ai/course-ai.client';
import { CourseAreaService } from './course-area.service';
import {
defaultConditions,
parseHhmm,
toCourseArea,
} from './course-conditions';
import type { CourseLang } from './course-generation.constants';
import type { CourseConditions } from './course-generation.types';
import { CourseSettings } from './course-settings';
import type {
ParseConditionsRequestDto,
ParseConditionsResponseDto,
} from './dto/course-conditions.dto';
/**
* 조건 해석(설계 §2) — 문장에서 뽑은 조건에 기본값을 채우고 그 날짜·지역의 예보를 붙인다. 횟수를 차감하지 않는다.
* AI 호출이 실패하면 기본값만 채워 돌려준다(입력 화면을 막지 않는다). 4단계 가짜 AI 는 문장을 읽지 않는다.
*/
@Injectable()
export class CourseConditionsService {
private readonly logger = new Logger(CourseConditionsService.name);
constructor(
private readonly areas: CourseAreaService,
private readonly weather: WeatherService,
private readonly settings: CourseSettings,
@Inject(COURSE_AI_CLIENT) private readonly ai: CourseAiClient,
) {}
async parse(
dto: ParseConditionsRequestDto,
lang: CourseLang,
now = new Date(),
): Promise<ParseConditionsResponseDto> {
const baseArea = toCourseArea(dto.baseArea);
// 앱의 기준 위치가 대구 밖이면 앱이 이미 도심으로 바꿔 보낸다 — 그래도 밖이면 VAL_001
await this.areas.resolve(baseArea);
const parsed = await this.parseText(dto.text, lang, now);
const conditions: CourseConditions = {
...defaultConditions(baseArea, now),
...parsed?.extracted,
};
const { center } = await this.areas.resolve(conditions.area);
const start = parseHhmm(conditions.start);
const end = parseHhmm(conditions.end);
const weather =
start !== null && end !== null
? await this.weather.forecastWindow(
center,
conditions.date,
start,
end,
now,
)
: null;
return {
conditions,
inferred: parsed?.inferred ?? [],
issues: parsed?.issues ?? [],
weather,
};
}
private async parseText(
text: string,
lang: CourseLang,
now: Date,
): Promise<ConditionParseOutput | null> {
const masked = maskContact(text.trim());
if (!masked) return null;
const { date, minutes } = kstClock(now);
try {
return await this.ai.parseConditions({
text: masked,
lang,
today: date,
nowMinutes: minutes,
signal: AbortSignal.timeout(this.settings.aiTimeoutMs),
});
} catch (error) {
this.logger.warn(
`[AI_002] 조건 해석을 하지 못해 기본값만 채운다: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
}
@@ -0,0 +1,126 @@
import {
conditionsProblem,
defaultConditions,
formatHhmm,
parseHhmm,
toStoredConditions,
} from './course-conditions';
import type { CourseConditions } from './course-generation.types';
const kst = (iso: string) => new Date(`${iso}+09:00`);
const DOWNTOWN = { type: 'neighborhood', key: 'downtown' } as const;
function conditions(
overrides: Partial<CourseConditions> = {},
): CourseConditions {
return {
area: DOWNTOWN,
date: '2026-11-14',
start: '13:00',
end: '19:00',
party: 2,
pet: null,
mobility: 'walk',
indoorIfRain: true,
...overrides,
};
}
describe('formatHhmm · parseHhmm', () => {
it('분과 HH:mm 을 오간다(24:00 까지)', () => {
expect(formatHhmm(13 * 60 + 5)).toBe('13:05');
expect(formatHhmm(1440)).toBe('24:00');
expect(parseHhmm('09:30')).toBe(570);
expect(parseHhmm('24:00')).toBe(1440);
expect(parseHhmm('24:30')).toBeNull();
expect(parseHhmm('9:30')).toBeNull();
});
});
describe('defaultConditions', () => {
it('오늘이면 다음 정시부터 6시간이다', () => {
const c = defaultConditions(DOWNTOWN, kst('2026-11-08T10:20:00'));
expect([c.date, c.start, c.end]).toEqual(['2026-11-08', '11:00', '17:00']);
expect(c).toMatchObject({
party: 2,
pet: null,
mobility: 'walk',
indoorIfRain: true,
});
});
it('끝은 22시까지이고, 18시 이후면 내일 13시부터다', () => {
const late = defaultConditions(DOWNTOWN, kst('2026-11-08T17:30:00'));
expect([late.date, late.start, late.end]).toEqual([
'2026-11-08',
'18:00',
'22:00',
]);
const evening = defaultConditions(DOWNTOWN, kst('2026-11-08T18:00:00'));
expect([evening.date, evening.start, evening.end]).toEqual([
'2026-11-09',
'13:00',
'19:00',
]);
});
});
describe('conditionsProblem', () => {
const now = kst('2026-11-08T15:00:00');
it('오늘~30일 뒤, 2~12시간, 자정 전이면 문제없다', () => {
expect(conditionsProblem(conditions(), now)).toBeNull();
expect(
conditionsProblem(conditions({ date: '2026-12-08', end: '24:00' }), now),
).toBeNull();
});
it('지난 날짜·30일보다 먼 날짜·없는 날짜를 거른다', () => {
expect(conditionsProblem(conditions({ date: '2026-11-07' }), now)).toBe(
'past_date',
);
expect(conditionsProblem(conditions({ date: '2026-12-09' }), now)).toBe(
'date_out_of_range',
);
expect(conditionsProblem(conditions({ date: '2026-02-30' }), now)).toBe(
'invalid_date',
);
});
it('2시간보다 짧거나 12시간보다 길거나 끝이 시작보다 이르면 거른다', () => {
expect(conditionsProblem(conditions({ end: '14:30' }), now)).toBe(
'too_short',
);
expect(
conditionsProblem(conditions({ start: '09:00', end: '21:30' }), now),
).toBe('too_long');
expect(
conditionsProblem(conditions({ start: '19:00', end: '13:00' }), now),
).toBe('too_short');
});
it('오늘 이미 끝난 시간과 대구 밖 현재 위치를 거른다', () => {
expect(
conditionsProblem(
conditions({ date: '2026-11-08', start: '10:00', end: '14:00' }),
now,
),
).toBe('past_time');
expect(
conditionsProblem(
conditions({ area: { type: 'point', lat: 37.5665, lng: 126.978 } }),
now,
),
).toBe('unsupported_area');
});
});
describe('toStoredConditions', () => {
it('현재 위치는 약 100m(소수점 셋째 자리)까지만 남긴다', () => {
const stored = toStoredConditions(
conditions({ area: { type: 'point', lat: 35.871234, lng: 128.594567 } }),
);
expect(stored.area).toEqual({ type: 'point', lat: 35.871, lng: 128.595 });
expect(toStoredConditions(conditions()).area).toEqual(DOWNTOWN);
});
});
@@ -0,0 +1,142 @@
/**
* 코스 조건 기본값·검사·변환 (순수 함수) — 설계 §2.
*/
import { isInSido } from '@/shared/utils/geo';
import { addDaysIso, daysBetweenIso } from '@/shared/utils/iso-date';
import { kstClock } from '@/shared/utils/kst';
import {
DATE_RANGE_DAYS,
DEFAULT_END_LIMIT_MINUTES,
DEFAULT_LENGTH_MINUTES,
DEFAULT_PARTY,
DEFAULT_START_MINUTES,
SUPPORTED_SIDO,
TOMORROW_FROM_MINUTES,
WINDOW_MAX_MINUTES,
WINDOW_MIN_MINUTES,
} from './course-generation.constants';
import type { CourseArea, CourseConditions } from './course-generation.types';
import type {
CourseAreaDto,
CourseConditionsDto,
} from './dto/course-conditions.dto';
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_DAY = 1440;
/** 저장할 때 현재 위치를 남기는 자릿수(소수점 셋째 자리 ≈ 100m) */
const STORED_POINT_DECIMALS = 3;
/** 분 → 'HH:mm'('24:00' 까지) */
export function formatHhmm(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
/** 'HH:mm' → 분. 형식이 다르면 null('24:00' 은 1440) */
export function parseHhmm(text: string): number | null {
const m = /^(\d{2}):(\d{2})$/.exec(text);
if (!m) return null;
const minutes = Number(m[1]) * MINUTES_PER_HOUR + Number(m[2]);
return Number(m[2]) < MINUTES_PER_HOUR && minutes <= MINUTES_PER_DAY
? minutes
: null;
}
/**
* 문장에서 뽑지 못한 항목의 기본값(§2).
* 날짜는 오늘(18시 이후면 내일), 시작은 오늘이면 다음 정시·아니면 13:00, 끝은 시작 + 6시간(22시까지).
*/
export function defaultConditions(
area: CourseArea,
now: Date,
): CourseConditions {
const { date: today, minutes } = kstClock(now);
const tomorrow = minutes >= TOMORROW_FROM_MINUTES;
const date = tomorrow ? (addDaysIso(today, 1) ?? today) : today;
const start = tomorrow
? DEFAULT_START_MINUTES
: (Math.floor(minutes / MINUTES_PER_HOUR) + 1) * MINUTES_PER_HOUR;
const end = Math.min(
start + DEFAULT_LENGTH_MINUTES,
DEFAULT_END_LIMIT_MINUTES,
);
return {
area,
date,
start: formatHhmm(start),
end: formatHhmm(end),
party: DEFAULT_PARTY,
pet: null,
mobility: 'walk',
indoorIfRain: true,
};
}
/**
* 생성할 수 없는 조건이면 이유(로그용), 괜찮으면 null.
* 날짜는 오늘~30일 뒤, 시간은 2~12시간에 자정을 넘기지 않고, 오늘이면 아직 끝나지 않은 시간이어야 한다.
* 현재 위치는 대구 안이어야 한다(장소·동네는 서버가 찾는다).
*/
export function conditionsProblem(
c: CourseConditions,
now: Date,
): string | null {
const { date: today, minutes } = kstClock(now);
const ahead = daysBetweenIso(today, c.date);
if (!Number.isFinite(ahead) || addDaysIso(c.date, 0) !== c.date) {
return 'invalid_date';
}
if (ahead < 0) return 'past_date';
if (ahead > DATE_RANGE_DAYS) return 'date_out_of_range';
const start = parseHhmm(c.start);
const end = parseHhmm(c.end);
if (start === null || end === null) return 'invalid_time';
const length = end - start;
if (length < WINDOW_MIN_MINUTES) return 'too_short';
if (length > WINDOW_MAX_MINUTES) return 'too_long';
if (ahead === 0 && end <= minutes) return 'past_time';
if (
c.area.type === 'point' &&
!isInSido(SUPPORTED_SIDO, c.area.lat, c.area.lng)
) {
return 'unsupported_area';
}
return null;
}
export function toCourseArea(dto: CourseAreaDto): CourseArea {
if (dto.type === 'neighborhood') {
return { type: 'neighborhood', key: dto.key ?? 'downtown' };
}
if (dto.type === 'point') {
return { type: 'point', lat: dto.lat ?? 0, lng: dto.lng ?? 0 };
}
return { type: 'place', placeId: dto.placeId ?? '' };
}
export function toCourseConditions(dto: CourseConditionsDto): CourseConditions {
return {
area: toCourseArea(dto.area),
date: dto.date,
start: dto.start,
end: dto.end,
party: dto.party,
pet: dto.pet
? { kind: dto.pet.kind, size: dto.pet.size ?? null, count: dto.pet.count }
: null,
mobility: dto.mobility,
indoorIfRain: dto.indoorIfRain,
};
}
/** 생성 기록에 남길 조건 — 현재 위치는 약 100m 단위로만 남긴다 */
export function toStoredConditions(c: CourseConditions): CourseConditions {
if (c.area.type !== 'point') return c;
const round = (v: number) => Number(v.toFixed(STORED_POINT_DECIMALS));
return {
...c,
area: { type: 'point', lat: round(c.area.lat), lng: round(c.area.lng) },
};
}
@@ -0,0 +1,99 @@
/**
* AI 코스 생성 API 의 기준값 — 설계 docs/design/ai-course.md §2·§3.1·§4·§5.
*/
import type { LatLng } from '@/shared/utils/geo';
/** 모든 AI 코스 요청에 앱이 싣는 기기 ID(앱이 처음 실행할 때 만든 UUID, flutter_secure_storage 에 둔다) */
export const DEVICE_ID_HEADER = 'x-device-id';
/** 지원 동네 — 앱의 Neighborhood(flutter/lib/features/location/domain/base_location.dart)와 같은 키·좌표 */
export const NEIGHBORHOODS = {
downtown: { lat: 35.87021, lng: 128.59409 },
suseongmot: { lat: 35.8276, lng: 128.6177 },
apsan: { lat: 35.83582, lng: 128.57911 },
} as const satisfies Record<string, LatLng>;
export type NeighborhoodKey = keyof typeof NEIGHBORHOODS;
export const NEIGHBORHOOD_KEYS = Object.keys(
NEIGHBORHOODS,
) as NeighborhoodKey[];
/** 대구 밖 위치를 대신하는 동네(앱과 같다) */
export const FALLBACK_NEIGHBORHOOD: NeighborhoodKey = 'downtown';
/** 지원 지역 — 법정동 시·도 코드(대구) */
export const SUPPORTED_SIDO = '27';
export const AREA_TYPES = ['neighborhood', 'point', 'place'] as const;
export type AreaType = (typeof AREA_TYPES)[number];
export const PET_KINDS = ['dog', 'cat', 'other'] as const;
export const COURSE_LANGS = ['ko', 'en'] as const;
export type CourseLang = (typeof COURSE_LANGS)[number];
export const GENERATION_STATUSES = [
'queued',
'running',
'succeeded',
'failed',
'canceled',
] as const;
export type GenerationStatus = (typeof GENERATION_STATUSES)[number];
/** 끝나지 않은 상태 — 기기·사용자마다 1건만 둔다(§5) */
export const ACTIVE_STATUSES: readonly GenerationStatus[] = [
'queued',
'running',
];
export const GENERATION_STAGES = [
'searching',
'filtering',
'planning',
'finishing',
] as const;
export type GenerationStage = (typeof GENERATION_STAGES)[number];
/** 생성 작업 안에서 생긴 실패 코드(응답 상태가 아니라 failure.code 로 준다, §7) */
export const FAILURE_CODES = ['AI_002', 'AI_003'] as const;
export type FailureCode = (typeof FAILURE_CODES)[number];
export const RATING_VALUES = ['like', 'dislike'] as const;
export type RatingValue = (typeof RATING_VALUES)[number];
/** 하루 횟수 기본값(§13) — AI_DAILY_LIMIT_GUEST · AI_DAILY_LIMIT_MEMBER 로 바꾼다 */
export const DAILY_LIMIT_GUEST_DEFAULT = 2;
export const DAILY_LIMIT_MEMBER_DEFAULT = 5;
/** 실패·취소를 포함한 하루 시도는 횟수의 이 배수까지(취소를 반복해 비용만 쓰는 것을 막는다) */
export const ATTEMPT_LIMIT_MULTIPLIER = 3;
/** 하루 비용 상한(정수 달러, 조건 해석 포함) — AI_DAILY_BUDGET_USD 로 바꾼다. 개발 3달러(§13) */
export const DAILY_BUDGET_USD_DEFAULT = 3;
/** AI 호출 제한 시간(ms) — AI_TIMEOUT_MS 로 바꾼다 */
export const AI_TIMEOUT_MS_DEFAULT = 30_000;
/** 생성 작업 전체 제한 시간(AI 호출 30초 + 재시도 여유) — 넘으면 failed + AI_002 */
export const JOB_TIMEOUT_MS = 45_000;
/** 동시에 도는 생성 작업 수 — 넘으면 queued 로 기다린다 */
export const MAX_CONCURRENT_JOBS = 5;
/** 이보다 오래 running 인 작업은 멈춘 것으로 보고 failed 로 바꾼다 */
export const STALE_RUNNING_MS = 2 * 60_000;
/** 멈춘 작업을 찾는 간격 */
export const STALE_SWEEP_INTERVAL_MS = 60_000;
/** 요청 문장 최대 길이 */
export const REQUEST_TEXT_MAX = 300;
/** 날짜는 오늘부터 이만큼 뒤까지(§2) */
export const DATE_RANGE_DAYS = 30;
/** 시간 범위 길이(분) — 2~12시간(§2) */
export const WINDOW_MIN_MINUTES = 120;
export const WINDOW_MAX_MINUTES = 720;
/** 기본 시간(§2) — 오늘이면 다음 정시, 아니면 13:00 부터 6시간, 22시까지. 18시 이후면 내일 */
export const DEFAULT_START_MINUTES = 13 * 60;
export const DEFAULT_LENGTH_MINUTES = 6 * 60;
export const DEFAULT_END_LIMIT_MINUTES = 22 * 60;
export const TOMORROW_FROM_MINUTES = 18 * 60;
export const DEFAULT_PARTY = 2;
export const PARTY_MAX = 10;
export const PET_COUNT_MAX = 5;
/** 요청 제한(분당, §4) — 통신사 NAT 뒤에서는 여러 사용자가 같은 IP 를 쓴다 */
export const THROTTLE_WINDOW_MS = 60_000;
export const THROTTLE_GENERATE = 5;
export const THROTTLE_CONDITIONS = 20;
export const THROTTLE_POLLING = 300;
@@ -0,0 +1,182 @@
import {
Body,
Controller,
Get,
Headers,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
} from '@nestjs/common';
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
type CourseLang,
THROTTLE_CONDITIONS,
THROTTLE_GENERATE,
THROTTLE_POLLING,
THROTTLE_WINDOW_MS,
} from './course-generation.constants';
import { CourseConditionsService } from './course-conditions.service';
import { CourseGenerationService } from './course-generation.service';
import { DeviceHash } from './device-id.decorator';
import {
ParseConditionsRequestDto,
ParseConditionsResponseDto,
} from './dto/course-conditions.dto';
import {
CourseGenerationCreatedDto,
CourseGenerationDto,
CourseQuotaDto,
CreateCourseGenerationDto,
RateCourseGenerationDto,
} from './dto/course-generation.dto';
const DEVICE_HEADER_DOC = {
name: 'X-Device-Id',
required: true,
description:
'앱이 처음 실행할 때 만든 기기 UUID — 비로그인 하루 횟수와 "만든 기기만 보기"에 쓴다(원문은 저장하지 않는다)',
};
/** Accept-Language 가 영어면 영어로 쓴다(그 밖은 한국어) */
function langOf(acceptLanguage: string | undefined): CourseLang {
return acceptLanguage?.trim().toLowerCase().startsWith('en') ? 'en' : 'ko';
}
/**
* AI 코스 생성(설계 docs/design/ai-course.md §4) — 로그인 없이 쓴다. 라우팅과 입출력만 맡는다.
* 생성은 백그라운드 작업이고 앱이 1~2초마다 진행을 조회한다(스트리밍 대신 조회, §3.1).
*/
@ApiTags('CourseGeneration')
@ApiHeader(DEVICE_HEADER_DOC)
@Controller('course-generations')
export class CourseGenerationController {
constructor(private readonly generations: CourseGenerationService) {}
// ':id' 보다 먼저 둔다(경로가 겹친다)
@Get('quota')
@Throttle({ default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_POLLING } })
@ApiOperation({
summary: '오늘 남은 횟수',
description:
'입력 화면의 "오늘 2번 만들 수 있어요". 결과가 나온 생성만 차감한다.',
})
@ApiStandardResponse(CourseQuotaDto, { description: '하루 횟수' })
@ApiErrorResponse('VAL_001', 'X-Device-Id 가 없거나 UUID 가 아님')
quota(@DeviceHash() deviceHash: string) {
return this.generations.quotaFor(deviceHash);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@Throttle({ default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_GENERATE } })
@ApiOperation({
summary: '코스 생성 시작',
description:
'조건을 확인하고 백그라운드 작업을 시작한다. 진행은 GET /course-generations/{id} 로 조회한다.',
})
@ApiStandardResponse(CourseGenerationCreatedDto, {
status: HttpStatus.CREATED,
description: '생성 접수',
})
@ApiErrorResponse('VAL_001', '조건 오류(날짜·시간·지역·이전 생성 id)')
@ApiErrorResponse('AI_001', '하루 횟수를 다 씀')
@ApiErrorResponse('AI_002', 'AI 를 쓸 수 없음(하루 비용 상한 등)')
@ApiErrorResponse('AI_004', '이미 만드는 중')
create(
@DeviceHash() deviceHash: string,
@Body() dto: CreateCourseGenerationDto,
@Headers('accept-language') acceptLanguage?: string,
) {
return this.generations.create(deviceHash, dto, langOf(acceptLanguage));
}
@Get(':id')
@Throttle({ default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_POLLING } })
@ApiOperation({
summary: '생성 진행·결과 조회',
description:
'단계(stage)와 숫자, 결과(succeeded)·실패 이유(failure)를 준다. 생성이 실패해도 조회는 200 이다. 만든 기기만 볼 수 있다.',
})
@ApiStandardResponse(CourseGenerationDto, { description: '생성 상태' })
@ApiErrorResponse('VAL_001', 'id 형식 오류 · X-Device-Id 오류')
@ApiErrorResponse('RES_001', '없거나 다른 기기의 생성')
get(
@DeviceHash() deviceHash: string,
@Param('id', new ParseUUIDPipe()) id: string,
) {
return this.generations.get(deviceHash, id);
}
@Post(':id/cancellation')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '생성 취소',
description:
'AI 호출을 끊는다. 차감하지 않는다. 이미 끝난 생성은 그대로 돌려준다.',
})
@ApiStandardResponse(CourseGenerationDto, { description: '취소한 생성' })
@ApiErrorResponse('RES_001', '없거나 다른 기기의 생성')
cancel(
@DeviceHash() deviceHash: string,
@Param('id', new ParseUUIDPipe()) id: string,
) {
return this.generations.cancel(deviceHash, id);
}
@Put(':id/rating')
@ApiOperation({
summary: '코스 평가',
description: '"이 코스, 마음에 드세요?" — 결과가 나온 생성만 평가한다.',
})
@ApiStandardResponse(CourseGenerationDto, { description: '평가한 생성' })
@ApiErrorResponse('VAL_001', '평가 값 오류 · 결과가 나오지 않은 생성')
@ApiErrorResponse('RES_001', '없거나 다른 기기의 생성')
rate(
@DeviceHash() deviceHash: string,
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: RateCourseGenerationDto,
) {
return this.generations.rate(deviceHash, id, dto.value);
}
}
/** 조건 해석(설계 §2) — 입력이 1초 멈추거나 키보드 '완료'일 때 한 번 부른다. 횟수를 차감하지 않는다 */
@ApiTags('CourseGeneration')
@ApiHeader(DEVICE_HEADER_DOC)
@Controller('course-conditions')
export class CourseConditionsController {
constructor(private readonly conditions: CourseConditionsService) {}
@Post()
@HttpCode(HttpStatus.OK)
@Throttle({
default: { ttl: THROTTLE_WINDOW_MS, limit: THROTTLE_CONDITIONS },
})
@ApiOperation({
summary: '조건 해석',
description:
'문장에서 지역·날짜·시간 같은 조건을 뽑고 비어 있는 항목은 기본값으로 채운다. 예보를 함께 준다. AI 를 못 쓰면 기본값만 채운다.',
})
@ApiStandardResponse(ParseConditionsResponseDto, { description: '채운 조건' })
@ApiErrorResponse(
'VAL_001',
'문장·기준 위치 오류(대구 밖 등) · X-Device-Id 오류',
)
parse(
@DeviceHash() _deviceHash: string,
@Body() dto: ParseConditionsRequestDto,
@Headers('accept-language') acceptLanguage?: string,
) {
return this.conditions.parse(dto, langOf(acceptLanguage));
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FestivalEvent } from '@/modules/place/entities/festival-event.entity';
import { PlaceListing } from '@/modules/place/entities/place-listing.entity';
import { WeatherModule } from '@/modules/weather/weather.module';
import { COURSE_AI_CLIENT } from './ai/course-ai.client';
import { FakeCourseAiClient } from './ai/fake-course-ai.client';
import { CourseAreaService } from './course-area.service';
import { CourseCandidatesService } from './course-candidates.service';
import { CourseConditionsService } from './course-conditions.service';
import {
CourseConditionsController,
CourseGenerationController,
} from './course-generation.controller';
import { CourseGenerationPipeline } from './course-generation.pipeline';
import { CourseGenerationRunner } from './course-generation.runner';
import { CourseGenerationService } from './course-generation.service';
import { CourseQuotaService } from './course-quota.service';
import { CourseSettings } from './course-settings';
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
/**
* AI 코스 생성(설계 docs/design/ai-course.md). 시간표 짜기는 순수 로직(planner/), 날씨는 WeatherModule 이다.
* AI 는 4단계에서 가짜(FakeCourseAiClient)를 쓴다 — 5단계에서 Anthropic 클라이언트로 바꾸고, 테스트는 계속 가짜를 쓴다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([AiCourseGeneration, PlaceListing, FestivalEvent]),
WeatherModule,
],
controllers: [CourseGenerationController, CourseConditionsController],
providers: [
CourseSettings,
CourseAreaService,
CourseCandidatesService,
CourseQuotaService,
CourseConditionsService,
CourseGenerationPipeline,
CourseGenerationRunner,
CourseGenerationService,
{ provide: COURSE_AI_CLIENT, useClass: FakeCourseAiClient },
],
})
export class CourseGenerationModule {}
@@ -0,0 +1,249 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { WeatherService } from '@/modules/weather/weather.service';
import { holidayOn, isHolidayTableYear } from '@/shared/utils/kr-holidays';
import {
type AiUsage,
COURSE_AI_CLIENT,
type CourseAiClient,
type CourseDraftOutput,
} from './ai/course-ai.client';
import { CourseAreaService } from './course-area.service';
import { CourseCandidatesService } from './course-candidates.service';
import { parseHhmm } from './course-conditions';
import {
type GenerationStage,
JOB_TIMEOUT_MS,
} from './course-generation.constants';
import type { ResolvedConditions } from './course-generation.types';
import { buildCourseResult } from './course-result.builder';
import { CourseSettings } from './course-settings';
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
import { prefersIndoor } from './planner/candidate';
import { filterCandidates } from './planner/filter';
import { planCourse } from './planner/plan-course';
import { STOPS_MIN } from './planner/planner.constants';
import type { PlanContext, PlanResult } from './planner/planner.types';
import { shortlistCandidates } from './planner/shortlist';
/** 생성 작업이 끝나지 못한 이유 — 러너가 상태와 실패 코드로 바꾼다 */
export class CourseFailure extends Error {
constructor(
readonly code: 'AI_002' | 'AI_003',
readonly notices: string[] = [],
message: string = code,
) {
super(message);
this.name = 'CourseFailure';
}
}
interface UsageTotals {
model: string | null;
promptVersion: string | null;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
costUsd: number;
llmCalls: number;
}
function addUsage(totals: UsageTotals, usage: AiUsage): UsageTotals {
return {
model: usage.model,
promptVersion: usage.promptVersion,
inputTokens: totals.inputTokens + usage.inputTokens,
outputTokens: totals.outputTokens + usage.outputTokens,
cacheReadTokens: totals.cacheReadTokens + usage.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens + usage.cacheWriteTokens,
costUsd: totals.costUsd + usage.costUsd,
llmCalls: totals.llmCalls + 1,
};
}
function throwIfAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason));
}
}
/**
* 생성 작업 한 번의 단계(설계 §3.1) — 후보 찾기 → 거르기·줄이기 → AI 호출 → 시간표·검증·고치기 → 마무리.
* 단계와 숫자를 DB 에 남겨 앱이 진행을 조회한다. 상태 변경은 running 일 때만 해서, 그사이 취소된 생성을 덮어쓰지 않는다.
*/
@Injectable()
export class CourseGenerationPipeline {
private readonly logger = new Logger(CourseGenerationPipeline.name);
constructor(
@InjectRepository(AiCourseGeneration)
private readonly generations: Repository<AiCourseGeneration>,
private readonly areas: CourseAreaService,
private readonly candidates: CourseCandidatesService,
private readonly weather: WeatherService,
private readonly settings: CourseSettings,
@Inject(COURSE_AI_CLIENT) private readonly ai: CourseAiClient,
) {}
async run(row: AiCourseGeneration, signal: AbortSignal): Promise<void> {
const startedAt = Date.now();
const conditions = row.conditions;
const start = parseHhmm(conditions.start) ?? 0;
const end = parseHhmm(conditions.end) ?? 0;
await this.stage(row.id, 'searching');
const area = await this.areas.resolve(conditions.area);
const resolved: ResolvedConditions = {
conditions,
center: area.center,
placeName: area.placeName,
start,
end,
};
const [found, weather, previousIds] = await Promise.all([
this.candidates.find(area.center, conditions.date, conditions.mobility),
this.weather.forecastWindow(area.center, conditions.date, start, end),
this.previousIds(row),
]);
await this.update(row.id, { foundCount: found.candidates.length });
throwIfAborted(signal);
await this.stage(row.id, 'filtering');
if (!isHolidayTableYear(conditions.date)) {
this.logger.warn(
`공휴일 표에 ${conditions.date.slice(0, 4)}년이 없다 — kr-holidays.ts 를 갱신해야 한다`,
);
}
const holiday = holidayOn(conditions.date);
const ctx: PlanContext = {
date: conditions.date,
start,
end,
mobility: conditions.mobility,
center: area.center,
pet: conditions.pet,
holiday: holiday?.kind ?? null,
preferIndoor: prefersIndoor(
weather?.precipitationProbability ?? null,
conditions.indoorIfRain,
),
previousIds,
};
const { kept } = filterCandidates(found.candidates, ctx);
const shortlist = shortlistCandidates(kept, ctx);
await this.update(row.id, { keptCount: kept.length });
if (shortlist.length < STOPS_MIN) throw new CourseFailure('AI_003');
throwIfAborted(signal);
await this.stage(row.id, 'planning');
let usage: UsageTotals = {
model: null,
promptVersion: null,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
costUsd: 0,
llmCalls: 0,
};
const input = {
lang: row.lang,
requestText: row.requestText ?? '',
resolved,
preferIndoor: ctx.preferIndoor,
weather,
holidayName: holiday?.name ?? null,
shortlist,
violations: [],
signal,
};
let draft: CourseDraftOutput = await this.ai.draftCourse(input);
usage = addUsage(usage, draft.usage);
let plan: PlanResult = planCourse(draft.draft, shortlist, ctx);
// 고치기 4 — 고칠 수 없으면 위반 목록을 붙여 한 번 더 부른다(제한 시간이 남았을 때만)
const elapsed = Date.now() - startedAt;
if (
plan.outcome === 'failed' &&
plan.cause === 'unrepairable' &&
elapsed + this.settings.aiTimeoutMs < JOB_TIMEOUT_MS
) {
throwIfAborted(signal);
draft = await this.ai.draftCourse({
...input,
violations: plan.violations,
});
usage = addUsage(usage, draft.usage);
plan = planCourse(draft.draft, shortlist, ctx);
}
await this.update(row.id, {
model: usage.model,
promptVersion: usage.promptVersion,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
costUsd: usage.costUsd,
llmCalls: usage.llmCalls,
repairs: plan.repairs,
});
if (plan.outcome === 'failed') {
throw new CourseFailure('AI_003', draft.draft.notices);
}
throwIfAborted(signal);
await this.stage(row.id, 'finishing');
const namesByRef = new Map(shortlist.map((e) => [e.ref, e.candidate.name]));
const result = buildCourseResult({
plan,
title: draft.title,
summary: draft.summary,
notices: draft.draft.notices,
resolved,
weather,
facts: found.facts,
namesByRef,
});
await this.update(row.id, {
status: 'succeeded',
stage: null,
result,
counted: true,
latencyMs: Date.now() - startedAt,
finishedAt: new Date(),
});
this.logger.log(
`코스 생성 ${row.id} 성공 · ${result.outcome} · 장소 ${result.stops.length} · ` +
`모델 ${usage.model} · 토큰 ${usage.inputTokens}/${usage.outputTokens} · $${usage.costUsd.toFixed(4)} · ` +
`${Date.now() - startedAt}ms · 고치기 ${plan.repairs.join(',') || '없음'}`,
);
}
/** 다시 만들기 — 이전 결과의 장소·행사 id */
private async previousIds(row: AiCourseGeneration): Promise<string[]> {
if (!row.previousId) return [];
const previous = await this.generations.findOneBy({
id: row.previousId,
deviceHash: row.deviceHash,
});
return previous?.result?.stops.map((s) => s.id) ?? [];
}
private stage(id: string, stage: GenerationStage): Promise<void> {
return this.update(id, { stage });
}
/** running 일 때만 바꾼다 — 취소된 생성을 덮어쓰지 않는다 */
private async update(
id: string,
values: QueryDeepPartialEntity<AiCourseGeneration>,
): Promise<void> {
await this.generations.update({ id, status: 'running' }, values);
}
}
@@ -0,0 +1,220 @@
import {
BeforeApplicationShutdown,
Injectable,
Logger,
OnApplicationBootstrap,
} from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, Repository } from 'typeorm';
import { AiUnavailableError } from './ai/course-ai.client';
import {
ACTIVE_STATUSES,
JOB_TIMEOUT_MS,
MAX_CONCURRENT_JOBS,
STALE_RUNNING_MS,
STALE_SWEEP_INTERVAL_MS,
} from './course-generation.constants';
import {
CourseFailure,
CourseGenerationPipeline,
} from './course-generation.pipeline';
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
/** 작업을 끊은 이유 */
type AbortReason = 'cancel' | 'timeout' | 'shutdown';
class JobAbort extends Error {
constructor(readonly reason: AbortReason) {
super(`생성 작업을 끊었다: ${reason}`);
this.name = 'JobAbort';
}
}
/**
* 생성 작업 실행기(설계 §3.1) — MVP 는 서버가 한 대라 프로세스 안에서 돌린다.
* - 동시에 5건까지 돌리고 나머지는 queued 로 기다린다
* - 작업마다 45초 제한(넘으면 failed + AI_002), 취소하면 AI 호출을 끊는다
* - 종료할 때 진행 중인 작업은 failed(차감 없음)로 남기고, 기동할 때 남은 작업도 failed 로 바꾼다
* 서버를 늘리면 Redis 큐(BullMQ)로 옮긴다.
*/
@Injectable()
export class CourseGenerationRunner
implements OnApplicationBootstrap, BeforeApplicationShutdown
{
private readonly logger = new Logger(CourseGenerationRunner.name);
private readonly queue: string[] = [];
private readonly active = new Map<string, AbortController>();
private shuttingDown = false;
constructor(
@InjectRepository(AiCourseGeneration)
private readonly generations: Repository<AiCourseGeneration>,
private readonly pipeline: CourseGenerationPipeline,
) {}
/**
* 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다.
* 정리일 뿐이라 실패해도 기동은 막지 않는다 — 마이그레이션 전이라 테이블이 없을 때 서버 전체가 뜨지 않았다(2026-11-11)
*/
async onApplicationBootstrap(): Promise<void> {
try {
const result = await this.generations.update(
{ status: In([...ACTIVE_STATUSES]) },
this.failedValues(),
);
if (result.affected) {
this.logger.warn(
`지난 실행에서 끝나지 못한 코스 생성 ${result.affected}건을 실패로 바꿨다`,
);
}
} catch (error) {
this.logger.error(
`[SYS_001] 끝나지 못한 코스 생성을 정리하지 못했다: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** DB 연결이 닫히기 전(onApplicationShutdown 앞)에 남은 작업을 실패로 적는다 */
async beforeApplicationShutdown(): Promise<void> {
this.shuttingDown = true;
const ids = [...this.queue, ...this.active.keys()];
this.queue.length = 0;
for (const controller of this.active.values()) {
controller.abort(new JobAbort('shutdown'));
}
if (ids.length > 0) {
await this.generations
.update(
{ id: In(ids), status: In([...ACTIVE_STATUSES]) },
this.failedValues(),
)
.catch(() => undefined);
}
}
/** 멈춘 작업(2분 넘게 running) 정리 — 작업 제한 시간(45초)이 있어 정상이면 생기지 않는다 */
@Interval(STALE_SWEEP_INTERVAL_MS)
async sweepStale(): Promise<void> {
const before = new Date(Date.now() - STALE_RUNNING_MS);
const result = await this.generations.update(
{ status: 'running', startedAt: LessThan(before) },
this.failedValues(),
);
if (result.affected) {
this.logger.warn(`멈춘 코스 생성 ${result.affected}건을 실패로 바꿨다`);
}
}
enqueue(id: string): void {
if (this.shuttingDown) return;
this.queue.push(id);
this.pump();
}
/** 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다. 이 서버에 없으면 false */
cancel(id: string): boolean {
const index = this.queue.indexOf(id);
if (index >= 0) {
this.queue.splice(index, 1);
return true;
}
const controller = this.active.get(id);
controller?.abort(new JobAbort('cancel'));
return Boolean(controller);
}
private pump(): void {
while (this.active.size < MAX_CONCURRENT_JOBS && this.queue.length > 0) {
const id = this.queue.shift();
if (!id) break;
const controller = new AbortController();
this.active.set(id, controller);
const timer = setTimeout(
() => controller.abort(new JobAbort('timeout')),
JOB_TIMEOUT_MS,
);
// 시작 기록(DB) 실패처럼 execute 밖으로 나온 오류도 여기서 받는다 — 처리되지 않은 거부는 프로세스를 내린다
void this.execute(id, controller.signal)
.catch((error: unknown) =>
this.logger.error(
`[SYS_001] 코스 생성 ${id} 를 시작하지 못했다: ${error instanceof Error ? error.message : String(error)}`,
),
)
.finally(() => {
clearTimeout(timer);
this.active.delete(id);
this.pump();
});
}
}
private async execute(id: string, signal: AbortSignal): Promise<void> {
const started = await this.generations.update(
{ id, status: 'queued' },
{ status: 'running', startedAt: new Date() },
);
if (!started.affected) return; // 그사이 취소됐다
const row = await this.generations.findOneBy({ id });
if (!row) return;
try {
await this.pipeline.run(row, signal);
} catch (error) {
await this.finishFailed(id, error, signal).catch((e: unknown) =>
this.logger.error(
`[SYS_001] 코스 생성 ${id} 의 실패를 기록하지 못했다: ${e instanceof Error ? e.message : String(e)}`,
),
);
}
}
private async finishFailed(
id: string,
error: unknown,
signal: AbortSignal,
): Promise<void> {
const reason: unknown = signal.aborted ? signal.reason : error;
if (reason instanceof JobAbort && reason.reason === 'cancel') {
await this.generations.update(
{ id, status: 'running' },
{ status: 'canceled', stage: null, finishedAt: new Date() },
);
return;
}
if (reason instanceof JobAbort && reason.reason === 'shutdown') return;
const detail = reason instanceof Error ? reason.message : 'unknown error';
const failure =
reason instanceof CourseFailure
? reason
: new CourseFailure('AI_002', [], detail);
if (!(reason instanceof CourseFailure)) {
if (reason instanceof AiUnavailableError || reason instanceof JobAbort) {
this.logger.warn(`[AI_002] 코스 생성 ${id}: ${detail}`);
} else {
this.logger.error(
`[AI_002] 코스 생성 ${id} 중 예상하지 못한 오류: ${detail}`,
);
}
}
await this.generations.update(
{ id, status: 'running' },
{
status: 'failed',
stage: null,
failureCode: failure.code,
failureNotices: failure.notices,
finishedAt: new Date(),
},
);
}
private failedValues() {
return {
status: 'failed' as const,
stage: null,
failureCode: 'AI_002' as const,
finishedAt: new Date(),
};
}
}
@@ -0,0 +1,179 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { maskContact } from '@/shared/utils/mask-contact';
import { CourseAreaService } from './course-area.service';
import {
conditionsProblem,
toCourseConditions,
toStoredConditions,
} from './course-conditions';
import {
ACTIVE_STATUSES,
type CourseLang,
type RatingValue,
} from './course-generation.constants';
import { CourseGenerationRunner } from './course-generation.runner';
import { CourseQuotaService } from './course-quota.service';
import type {
CourseGenerationCreatedDto,
CourseGenerationDto,
CourseQuotaDto,
CreateCourseGenerationDto,
} from './dto/course-generation.dto';
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
/**
* AI 코스 생성 API(설계 §4) — 횟수 확인, 생성 접수, 진행 조회, 취소, 평가.
* 만든 기기만 볼 수 있다(다르면 RES_001). 생성 작업 자체는 러너가 백그라운드로 돌린다.
*/
@Injectable()
export class CourseGenerationService {
constructor(
@InjectRepository(AiCourseGeneration)
private readonly generations: Repository<AiCourseGeneration>,
@InjectDataSource() private readonly dataSource: DataSource,
private readonly quota: CourseQuotaService,
private readonly areas: CourseAreaService,
private readonly runner: CourseGenerationRunner,
) {}
quotaFor(deviceHash: string, now = new Date()): Promise<CourseQuotaDto> {
return this.quota.view(deviceHash, now);
}
/**
* 생성 접수 — 조건을 확인하고, 같은 기기의 요청을 직렬화한 트랜잭션에서 횟수·진행 중·비용을 본 뒤 기록을 만든다.
* 차감은 결과가 나왔을 때(succeeded) 한다.
*/
async create(
deviceHash: string,
dto: CreateCourseGenerationDto,
lang: CourseLang,
now = new Date(),
): Promise<CourseGenerationCreatedDto> {
const conditions = toCourseConditions(dto.conditions);
const problem = conditionsProblem(conditions, now);
if (problem) {
throw new BusinessException('VAL_001', { context: { reason: problem } });
}
// 동네·장소가 실제로 있는지 접수할 때 확인한다(작업 안에서 다시 찾는다)
await this.areas.resolve(conditions.area);
if (dto.previousGenerationId) {
const previous = await this.generations.findOneBy({
id: dto.previousGenerationId,
deviceHash,
});
if (!previous) {
throw new BusinessException('VAL_001', {
context: { reason: 'previous_not_found' },
});
}
}
const row = await this.dataSource.transaction(async (manager) => {
// 같은 기기의 동시 요청이 함께 검사를 통과하지 않게 기기 단위로 잠근다(트랜잭션이 끝나면 풀린다)
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
deviceHash,
]);
await this.quota.assertCanCreate(manager, deviceHash, now);
const repo = manager.getRepository(AiCourseGeneration);
return repo.save(
repo.create({
deviceHash,
userId: null,
lang,
requestText: maskContact(dto.request.trim()),
conditions: toStoredConditions(conditions),
status: 'queued',
stage: null,
previousId: dto.previousGenerationId ?? null,
}),
);
});
this.runner.enqueue(row.id);
return {
id: row.id,
status: row.status,
remaining: await this.quota.remaining(deviceHash, now),
};
}
async get(
deviceHash: string,
id: string,
now = new Date(),
): Promise<CourseGenerationDto> {
return this.toView(await this.owned(deviceHash, id), now);
}
/** 취소 — 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다. 차감하지 않는다. 이미 끝났으면 그대로 돌려준다 */
async cancel(
deviceHash: string,
id: string,
now = new Date(),
): Promise<CourseGenerationDto> {
const row = await this.owned(deviceHash, id);
if (ACTIVE_STATUSES.includes(row.status)) {
this.runner.cancel(id);
await this.generations.update(
{ id, status: In([...ACTIVE_STATUSES]) },
{ status: 'canceled', stage: null, finishedAt: new Date() },
);
}
return this.get(deviceHash, id, now);
}
/** "이 코스, 마음에 드세요?" — 결과가 나온 생성만 평가한다 */
async rate(
deviceHash: string,
id: string,
value: RatingValue,
now = new Date(),
): Promise<CourseGenerationDto> {
const row = await this.owned(deviceHash, id);
if (row.status !== 'succeeded') {
throw new BusinessException('VAL_001', {
context: { reason: 'not_succeeded', status: row.status },
});
}
await this.generations.update(
{ id },
{ rating: value, ratedAt: new Date() },
);
return this.get(deviceHash, id, now);
}
private async owned(
deviceHash: string,
id: string,
): Promise<AiCourseGeneration> {
const row = await this.generations.findOneBy({ id, deviceHash });
if (!row) {
throw new BusinessException('RES_001', { context: { generationId: id } });
}
return row;
}
private async toView(
row: AiCourseGeneration,
now: Date,
): Promise<CourseGenerationDto> {
return {
id: row.id,
status: row.status,
stage: row.stage,
progress: { found: row.foundCount, kept: row.keptCount },
result: row.status === 'succeeded' ? row.result : null,
failure:
row.status === 'failed' && row.failureCode
? { code: row.failureCode, notices: row.failureNotices ?? [] }
: null,
remaining: await this.quota.remaining(row.deviceHash, now),
rating: row.rating,
};
}
}
@@ -0,0 +1,50 @@
/**
* AI 코스 생성의 조건 형식 — API 입력(CourseConditions, 설계 §2)과 생성 작업 안에서 쓰는 해석된 조건.
*/
import type { PetSize } from '@/modules/place/place.constants';
import type { LatLng } from '@/shared/utils/geo';
import type { Mobility } from './planner/planner.constants';
import type { PetCondition } from './planner/planner.types';
import type { NeighborhoodKey } from './course-generation.constants';
export type CourseArea =
| { type: 'neighborhood'; key: NeighborhoodKey }
| { type: 'point'; lat: number; lng: number }
| { type: 'place'; placeId: string };
/** 사용자가 화면에서 확인한 조건 — 조건 해석 응답과 생성 요청이 같은 형식을 쓴다 */
export interface CourseConditions {
area: CourseArea;
/** 'YYYY-MM-DD' — 오늘부터 30일 뒤까지 */
date: string;
/** 'HH:mm' — 2~12시간, 자정을 넘기지 않는다 */
start: string;
end: string;
party: number;
pet: {
kind: PetCondition['kind'];
size: PetSize | null;
count: number;
} | null;
mobility: Mobility;
indoorIfRain: boolean;
}
/**
* 생성 기록에 남기는 조건. 현재 위치(point)는 소수점 셋째 자리(약 100m)까지만 남긴다 —
* 생성이 끝나면 정확한 위치가 필요 없다.
*/
export type StoredConditions = CourseConditions;
/** 생성 작업이 쓰는 해석된 조건 */
export interface ResolvedConditions {
conditions: CourseConditions;
/** 지역 중심(동네 중심 · 현재 위치 · 기준 장소) */
center: LatLng;
/** 기준 장소의 이름(지역이 장소일 때) */
placeName: string | null;
/** 00:00 부터의 분 */
start: number;
end: number;
}
@@ -0,0 +1,117 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { kstStartOfDay } from '@/shared/utils/kst';
import {
ACTIVE_STATUSES,
ATTEMPT_LIMIT_MULTIPLIER,
} from './course-generation.constants';
import { CourseSettings } from './course-settings';
import type { CourseQuotaDto } from './dto/course-generation.dto';
import { AiCourseGeneration } from './entities/ai-course-generation.entity';
const DAY_MS = 86_400_000;
/**
* 하루 횟수와 비용 보호(설계 §5). 하루 사용량과 비용은 ai_course_generation 에서 센다(한국 시간 자정에 초기화).
* 로그인이 아직 없어 모두 비로그인(기기 기준)이다.
*/
@Injectable()
export class CourseQuotaService {
private readonly logger = new Logger(CourseQuotaService.name);
constructor(
@InjectRepository(AiCourseGeneration)
private readonly generations: Repository<AiCourseGeneration>,
private readonly settings: CourseSettings,
) {}
/** 입력 화면의 "오늘 2번 만들 수 있어요" */
async view(deviceHash: string, now: Date): Promise<CourseQuotaDto> {
const limit = this.settings.dailyLimitGuest;
const used = await this.countUsed(
this.generations.manager,
deviceHash,
now,
);
const dayStart = kstStartOfDay(now);
return {
limit,
used,
remaining: Math.max(0, limit - used),
resetsAt: new Date(dayStart.getTime() + DAY_MS).toISOString(),
tier: 'guest',
};
}
async remaining(deviceHash: string, now: Date): Promise<number> {
return (await this.view(deviceHash, now)).remaining;
}
/**
* 새 생성을 받아도 되는지 — 같은 기기의 요청을 직렬화한 트랜잭션 안에서 부른다.
* 진행 중이 있으면 AI_004, 하루 횟수나 시도 상한(횟수의 3배)을 넘으면 AI_001, 하루 비용 상한을 넘으면 AI_002.
*/
async assertCanCreate(
manager: EntityManager,
deviceHash: string,
now: Date,
): Promise<void> {
const dayStart = kstStartOfDay(now);
const repo = manager.getRepository(AiCourseGeneration);
const active = await repo
.createQueryBuilder('g')
.where('g.device_hash = :deviceHash', { deviceHash })
.andWhere('g.status IN (:...active)', { active: ACTIVE_STATUSES })
.getCount();
if (active > 0) {
throw new BusinessException('AI_004', { context: { reason: 'active' } });
}
const limit = this.settings.dailyLimitGuest;
const used = await this.countUsed(manager, deviceHash, now);
const attempts = await repo
.createQueryBuilder('g')
.where('g.device_hash = :deviceHash', { deviceHash })
.andWhere('g.created_at >= :dayStart', { dayStart })
.getCount();
if (used >= limit || attempts >= limit * ATTEMPT_LIMIT_MULTIPLIER) {
throw new BusinessException('AI_001', {
context: { used, attempts, limit },
});
}
const raw = await repo
.createQueryBuilder('g')
.select('COALESCE(SUM(g.cost_usd), 0)', 'spent')
.where('g.created_at >= :dayStart', { dayStart })
.getRawOne<{ spent: string | number }>();
const spent = Number(raw?.spent ?? 0);
if (spent >= this.settings.dailyBudgetUsd) {
// 키 유출·버그로 비용이 튀는 것을 막는 장치라 운영자가 바로 봐야 한다
this.logger.error(
`[AI_002] 하루 AI 비용 상한에 닿았다: $${spent.toFixed(4)} / $${this.settings.dailyBudgetUsd}`,
);
throw new BusinessException('AI_002', {
context: { reason: 'daily_budget', spent },
});
}
}
private countUsed(
manager: EntityManager,
deviceHash: string,
now: Date,
): Promise<number> {
return manager
.getRepository(AiCourseGeneration)
.createQueryBuilder('g')
.where('g.device_hash = :deviceHash', { deviceHash })
.andWhere('g.counted = true')
.andWhere('g.created_at >= :dayStart', { dayStart: kstStartOfDay(now) })
.getCount();
}
}
@@ -0,0 +1,128 @@
import type { CandidateFacts } from './course-candidates.service';
import type { ResolvedConditions } from './course-generation.types';
import { buildCourseResult, kstIso } from './course-result.builder';
import { planCourse } from './planner/plan-course';
import { at, CENTER, context, place } from './planner/planner.fixtures';
import type { Candidate, ShortlistEntry } from './planner/planner.types';
const facts = (provider: string): CandidateFacts => ({
hoursText: '10:00~22:00',
hoursOrigin: 'public',
petOrigin: 'unknown',
verifiedAt: null,
provider,
photo: { thumbUrl: 'https://example.test/a.jpg', license: 'Type3' },
eventPeriod: null,
});
describe('kstIso', () => {
it('그날 한국 시각을 UTC ISO 로 바꾼다', () => {
expect(kstIso('2026-11-07', at(14))).toBe('2026-11-07T05:00:00.000Z');
expect(kstIso('2026-11-07', at(8))).toBe('2026-11-06T23:00:00.000Z');
});
});
describe('buildCourseResult', () => {
it('시간표에 DB 의 사실 값·출처와 AI 의 제목·이유를 붙인다', () => {
const cafe: Candidate = place({
category: 'cafe',
hoursText: '10:00~22:00',
restText: '매주 월요일',
});
const museum = place({ category: 'culture', east: 300 });
const other = place({ category: 'cafe', north: 300 });
const list: ShortlistEntry[] = [
{ ref: 'P01', candidate: cafe, distanceM: 0 },
{ ref: 'P02', candidate: museum, distanceM: 300 },
{ ref: 'P03', candidate: other, distanceM: 300 },
];
const ctx = context();
const plan = planCourse(
{
stops: [
{ ref: 'P01', minutes: 60, reason: '창가 자리' },
{ ref: 'P02', minutes: 90, reason: '전시' },
],
alternates: [{ forStop: 1, ref: 'P03', reason: '다른 카페' }],
notices: [],
},
list,
ctx,
);
if (plan.outcome === 'failed') throw new Error('코스를 만들지 못했다');
const resolved: ResolvedConditions = {
conditions: {
area: { type: 'neighborhood', key: 'downtown' },
date: ctx.date,
start: '14:00',
end: '20:00',
party: 2,
pet: null,
mobility: 'walk',
indoorIfRain: true,
},
center: CENTER,
placeName: null,
start: ctx.start,
end: ctx.end,
};
const result = buildCourseResult({
plan,
title: '도심 산책',
summary: '카페와 전시',
notices: [],
resolved,
weather: {
date: ctx.date,
precipitationProbability: 20,
precipitationType: 'none',
sky: 'clear',
temperatureMin: 10,
temperatureMax: 15,
issuedAt: '2026-11-07T02:00:00.000Z',
},
facts: new Map([
[cafe.id, facts('한국관광공사')],
[museum.id, facts('한국관광공사')],
[other.id, facts('소상공인시장진흥공단 상가(상권)정보')],
]),
namesByRef: new Map(list.map((e) => [e.ref, e.candidate.name])),
});
expect(result.window).toEqual({
date: ctx.date,
start: '14:00',
end: '20:00',
});
expect(
result.stops.map((s) => [s.id, s.arrive, s.minutes, s.reason]),
).toEqual([
[cafe.id, '2026-11-07T05:00:00.000Z', 60, '창가 자리'],
[museum.id, '2026-11-07T06:10:00.000Z', 90, '전시'],
]);
expect(result.stops[0].facts).toMatchObject({
// 2026-11-07 은 토요일이라 연다
hoursOnDate: [{ start: '10:00', end: '22:00' }],
setting: 'indoor',
provider: '한국관광공사',
});
expect(result.stops[0].legToNext).toEqual({
minutes: 10,
meters: 300,
mode: 'walk',
estimated: true,
});
expect(result.alternates).toEqual([
expect.objectContaining({
id: other.id,
forIndex: 0,
reason: '다른 카페',
}),
]);
expect(result.sources).toEqual([
'한국관광공사',
'소상공인시장진흥공단 상가(상권)정보',
'기상청 단기예보',
]);
});
});
@@ -0,0 +1,177 @@
/**
* 생성 결과(설계 §3.7) 만들기 (순수 함수) — 시간표(planCourse)에 DB 의 사실 값·출처와 AI 의 제목·요약·이유를 붙인다.
* 화면에 보이는 사실 값은 모두 DB 에서 오고, AI 는 이유·요약만 쓴다(§1).
*/
import type { WindowForecast } from '@/modules/weather/kma-forecast';
import { hoursOn } from '@/shared/utils/opening-hours';
import { formatHhmm } from './course-conditions';
import type { CandidateFacts } from './course-candidates.service';
import type { ResolvedConditions } from './course-generation.types';
import type {
CourseAlternateDto,
CourseFactsDto,
CourseLegDto,
CourseResultDto,
CourseStopDto,
} from './dto/course-result.dto';
import { settingOf } from './planner/candidate';
import type {
Candidate,
Leg,
PlannedAlternate,
PlannedStop,
PlanResult,
StopWarning,
} from './planner/planner.types';
const KST_OFFSET_MINUTES = 9 * 60;
const WEATHER_SOURCE = '기상청 단기예보';
type SucceededPlan = Exclude<PlanResult, { outcome: 'failed' }>;
export interface ResultInput {
plan: SucceededPlan;
title: string;
summary: string;
/** AI 가 남긴 알림 */
notices: string[];
resolved: ResolvedConditions;
weather: WindowForecast | null;
facts: Map<string, CandidateFacts>;
/** 짧은 번호 → 이름(뺀 장소 알림에 쓴다) */
namesByRef: Map<string, string>;
}
/** 그날 한국 시각(분) → ISO 8601 UTC */
export function kstIso(date: string, minutes: number): string {
const [y, m, d] = date.split('-').map(Number);
return new Date(
Date.UTC(y, m - 1, d, 0, minutes - KST_OFFSET_MINUTES),
).toISOString();
}
function toLeg(leg: Leg | null): CourseLegDto | null {
return leg
? {
minutes: leg.minutes,
meters: leg.meters,
mode: leg.mode,
estimated: true,
}
: null;
}
function toFacts(
candidate: Candidate,
facts: CandidateFacts | undefined,
date: string,
): CourseFactsDto {
const ranges = candidate.hours ? hoursOn(candidate.hours, date) : null;
return {
hoursOnDate: ranges
? ranges.map((r) => ({
start: formatHhmm(r.start),
end: formatHhmm(r.end),
}))
: null,
hoursText: facts?.hoursText ?? null,
hoursOrigin: facts?.hoursOrigin ?? 'unknown',
petPolicy: candidate.petPolicy,
petOrigin: facts?.petOrigin ?? 'unknown',
petSizes: candidate.petSizes,
verifiedAt: facts?.verifiedAt
? new Date(facts.verifiedAt).toISOString()
: null,
setting: settingOf(candidate),
eventPeriod: facts?.eventPeriod ?? null,
provider: facts?.provider ?? '',
};
}
function toStop(
stop: Pick<PlannedStop, 'candidate' | 'arrive' | 'leave' | 'reason'> & {
warnings: StopWarning[];
},
input: ResultInput,
): Omit<CourseStopDto, 'legToNext'> {
const { candidate } = stop;
const facts = input.facts.get(candidate.id);
const date = input.resolved.conditions.date;
return {
kind: candidate.kind,
id: candidate.id,
name: candidate.name,
category: candidate.category,
eventKind: candidate.eventKind,
photo: facts?.photo ?? null,
arrive: kstIso(date, stop.arrive),
leave: kstIso(date, stop.leave),
minutes: stop.leave - stop.arrive,
reason: stop.reason,
facts: toFacts(candidate, facts, date),
warnings: stop.warnings,
};
}
function toAlternate(
alt: PlannedAlternate,
stops: PlannedStop[],
input: ResultInput,
): CourseAlternateDto {
return {
...toStop(alt, input),
legToNext: toLeg(alt.legToNext),
forIndex: stops.findIndex((s) => s.ref === alt.forRef),
legFromPrev: toLeg(alt.legFromPrev),
};
}
/** 출처 문구 — 코스에 쓴 장소·행사의 출처와 예보 */
function sourcesOf(
stops: CourseStopDto[],
alternates: CourseAlternateDto[],
weather: WindowForecast | null,
): string[] {
const out: string[] = [];
for (const s of [...stops, ...alternates]) {
if (s.facts.provider && !out.includes(s.facts.provider)) {
out.push(s.facts.provider);
}
}
if (weather) out.push(WEATHER_SOURCE);
return out;
}
export function buildCourseResult(input: ResultInput): CourseResultDto {
const { plan, resolved } = input;
const stops: CourseStopDto[] = plan.stops.map((s) => ({
...toStop(s, input),
legToNext: toLeg(s.legToNext),
}));
const alternates = plan.alternates
.map((a) => toAlternate(a, plan.stops, input))
.filter((a) => a.forIndex >= 0);
return {
outcome: plan.outcome,
title: input.title,
summary: input.summary,
notices: input.notices,
planNotices: plan.notices.map((n) => ({
code: n.code,
name: n.ref ? (input.namesByRef.get(n.ref) ?? null) : null,
cause: n.cause ?? null,
})),
weather: input.weather,
window: {
date: resolved.conditions.date,
start: formatHhmm(resolved.start),
end: formatHhmm(resolved.end),
},
mobility: resolved.conditions.mobility,
totals: plan.totals,
stops,
alternates,
sources: sourcesOf(stops, alternates, input.weather),
};
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { parsePositiveInt } from '@/shared/utils/env';
import {
AI_TIMEOUT_MS_DEFAULT,
DAILY_BUDGET_USD_DEFAULT,
DAILY_LIMIT_GUEST_DEFAULT,
DAILY_LIMIT_MEMBER_DEFAULT,
} from './course-generation.constants';
/**
* AI 코스 설정(설계 §11). 값이 없으면 코드 기본값을 쓴다 — 숫자 형식은 기동할 때 env.validation 이 본다.
* 키는 backend/.env.{환경} 이나 compose 로 넘긴다.
*/
@Injectable()
export class CourseSettings {
readonly dailyLimitGuest: number;
readonly dailyLimitMember: number;
readonly dailyBudgetUsd: number;
readonly aiTimeoutMs: number;
constructor(config: ConfigService) {
const read = (key: string, fallback: number) =>
parsePositiveInt(config.get<string>(key), fallback);
this.dailyLimitGuest = read(
'AI_DAILY_LIMIT_GUEST',
DAILY_LIMIT_GUEST_DEFAULT,
);
this.dailyLimitMember = read(
'AI_DAILY_LIMIT_MEMBER',
DAILY_LIMIT_MEMBER_DEFAULT,
);
this.dailyBudgetUsd = read('AI_DAILY_BUDGET_USD', DAILY_BUDGET_USD_DEFAULT);
this.aiTimeoutMs = read('AI_TIMEOUT_MS', AI_TIMEOUT_MS_DEFAULT);
}
}
@@ -0,0 +1,12 @@
import { hashDeviceId } from './device-id.decorator';
describe('hashDeviceId', () => {
it('같은 기기 ID 는 대소문자와 상관없이 같은 SHA-256 해시가 된다', () => {
const id = '3F1C0A2E-0F1B-4F2A-9A1E-6C9F0A1B2C3D';
expect(hashDeviceId(id)).toMatch(/^[0-9a-f]{64}$/);
expect(hashDeviceId(id)).toBe(hashDeviceId(id.toLowerCase()));
expect(hashDeviceId(id)).not.toBe(
hashDeviceId('00000000-0000-4000-8000-000000000000'),
);
});
});
@@ -0,0 +1,33 @@
import { createHash } from 'crypto';
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
import { DEVICE_ID_HEADER } from './course-generation.constants';
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** 기기 ID → SHA-256 해시(hex). 원문은 저장하지 않는다(설계 §5) */
export function hashDeviceId(deviceId: string): string {
return createHash('sha256').update(deviceId.toLowerCase()).digest('hex');
}
/**
* X-Device-Id 헤더(앱이 처음 실행할 때 만든 UUID)를 해시로 바꿔 넘긴다. 없거나 UUID 가 아니면 VAL_001.
* 비로그인 횟수와 '만든 기기만 볼 수 있음'을 이 값으로 가른다.
*/
export const DeviceHash = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest<Request>();
const deviceId = request.header(DEVICE_ID_HEADER)?.trim() ?? '';
if (!UUID_RE.test(deviceId)) {
throw new BusinessException('VAL_001', {
context: { reason: 'device_id' },
});
}
return hashDeviceId(deviceId);
},
);
@@ -0,0 +1,199 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsIn,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { PET_SIZES } from '@/modules/place/place.constants';
import type { PetSize } from '@/modules/place/place.constants';
import {
AREA_TYPES,
type AreaType,
NEIGHBORHOOD_KEYS,
type NeighborhoodKey,
PARTY_MAX,
PET_COUNT_MAX,
PET_KINDS,
REQUEST_TEXT_MAX,
} from '../course-generation.constants';
import { MOBILITIES, type Mobility } from '../planner/planner.constants';
import type { PetCondition } from '../planner/planner.types';
import { CourseWeatherDto } from './course-result.dto';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/** 'HH:mm' — 끝 시각은 자정('24:00')까지 쓸 수 있다 */
const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$|^24:00$/;
/** 지역 — 동네 · 현재 위치 · 기준 장소 중 하나(§2) */
export class CourseAreaDto {
@ApiProperty({
description:
'지역 종류 — neighborhood 동네 · point 현재 위치 · place 기준 장소("서문시장 근처")',
enum: AREA_TYPES,
example: 'neighborhood',
})
@IsIn(AREA_TYPES, { message: '지역 종류가 올바르지 않습니다.' })
type!: AreaType;
@ApiPropertyOptional({
description: '동네 키(type=neighborhood)',
enum: NEIGHBORHOOD_KEYS,
example: 'suseongmot',
})
@ValidateIf((o: CourseAreaDto) => o.type === 'neighborhood')
@IsIn(NEIGHBORHOOD_KEYS, { message: '지원하지 않는 동네입니다.' })
key?: NeighborhoodKey;
@ApiPropertyOptional({ description: '위도(type=point)', example: 35.8276 })
@ValidateIf((o: CourseAreaDto) => o.type === 'point')
@IsLatitude({ message: '위도가 올바르지 않습니다.' })
lat?: number;
@ApiPropertyOptional({ description: '경도(type=point)', example: 128.6177 })
@ValidateIf((o: CourseAreaDto) => o.type === 'point')
@IsLongitude({ message: '경도가 올바르지 않습니다.' })
lng?: number;
@ApiPropertyOptional({ description: '기준 장소 id(type=place)' })
@ValidateIf((o: CourseAreaDto) => o.type === 'place')
@IsUUID('all', { message: '장소 id 형식이 올바르지 않습니다.' })
placeId?: string;
}
/** 반려동물 */
export class CoursePetDto {
@ApiProperty({ description: '종류', enum: ['dog', 'cat', 'other'] })
@IsIn(PET_KINDS, { message: '반려동물 종류가 올바르지 않습니다.' })
kind!: PetCondition['kind'];
@ApiProperty({
description: '크기(모르면 null)',
enum: PET_SIZES,
nullable: true,
})
@IsOptional()
@IsIn(PET_SIZES, { message: '반려동물 크기가 올바르지 않습니다.' })
size!: PetSize | null;
@ApiProperty({ description: '마리 수', example: 1 })
@IsInt({ message: '반려동물 수는 정수로 입력해 주세요.' })
@Min(1, { message: '반려동물 수는 1 이상이어야 합니다.' })
@Max(PET_COUNT_MAX, {
message: `반려동물 수는 ${PET_COUNT_MAX} 이하여야 합니다.`,
})
count!: number;
}
/** 코스 조건(CourseConditions, §2) — 조건 해석 응답과 생성 요청이 같은 형식을 쓴다 */
export class CourseConditionsDto {
@ApiProperty({ description: '지역', type: CourseAreaDto })
@IsDefined({ message: '지역을 골라 주세요.' })
@ValidateNested()
@Type(() => CourseAreaDto)
area!: CourseAreaDto;
@ApiProperty({
description: '날짜(YYYY-MM-DD, 오늘~30일 뒤)',
example: '2026-11-14',
})
@Matches(DATE_RE, { message: '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.' })
date!: string;
@ApiProperty({ description: '시작 시각(HH:mm)', example: '13:00' })
@Matches(TIME_RE, { message: '시각은 HH:mm 형식으로 입력해 주세요.' })
start!: string;
@ApiProperty({
description: '끝 시각(HH:mm, 시작 + 2~12시간, 자정까지)',
example: '19:00',
})
@Matches(TIME_RE, { message: '시각은 HH:mm 형식으로 입력해 주세요.' })
end!: string;
@ApiProperty({ description: '인원(1~10)', example: 2 })
@IsInt({ message: '인원은 정수로 입력해 주세요.' })
@Min(1, { message: '인원은 1명 이상이어야 합니다.' })
@Max(PARTY_MAX, { message: `인원은 ${PARTY_MAX}명 이하여야 합니다.` })
party!: number;
@ApiProperty({
description: '반려동물(없으면 null)',
type: CoursePetDto,
nullable: true,
})
@IsOptional()
@ValidateNested()
@Type(() => CoursePetDto)
pet!: CoursePetDto | null;
@ApiProperty({ description: '이동 수단', enum: MOBILITIES, example: 'walk' })
@IsIn(MOBILITIES, { message: '이동 수단이 올바르지 않습니다.' })
mobility!: Mobility;
@ApiProperty({ description: '비가 오면 실내 위주로', example: true })
@IsBoolean({ message: 'indoorIfRain 은 true 또는 false 로 입력해 주세요.' })
indoorIfRain!: boolean;
}
/** POST /api/course-conditions 요청 */
export class ParseConditionsRequestDto {
@ApiProperty({
description: `사용자가 쓴 문장(${REQUEST_TEXT_MAX}자 이내, 비어도 된다)`,
example: '비 오는 토요일 오후, 강아지랑 수성못 데이트',
})
@IsString({ message: '문장은 문자열로 입력해 주세요.' })
@MaxLength(REQUEST_TEXT_MAX, {
message: `문장은 ${REQUEST_TEXT_MAX}자 이내로 입력해 주세요.`,
})
text!: string;
@ApiProperty({ description: '앱의 기준 위치', type: CourseAreaDto })
@IsDefined({ message: '기준 위치를 보내 주세요.' })
@ValidateNested()
@Type(() => CourseAreaDto)
baseArea!: CourseAreaDto;
}
/** POST /api/course-conditions 응답 */
export class ParseConditionsResponseDto {
@ApiProperty({ description: '채운 조건', type: CourseConditionsDto })
conditions!: CourseConditionsDto;
@ApiProperty({
description:
'문장에서 뽑은 항목(화면의 "적은 내용에서 자동으로 채웠어요") — area · date · start · end · party · pet · mobility · indoorIfRain',
type: [String],
example: ['date', 'pet'],
})
inferred!: string[];
@ApiProperty({
description:
'되묻거나 알릴 것 — unsupported_area · past_date · date_out_of_range · multi_day · too_short · past_midnight · unclear_request',
type: [String],
example: [],
})
issues!: string[];
@ApiProperty({
description: '그 날짜·지역의 예보(4일 뒤 이후·실패면 null)',
type: CourseWeatherDto,
nullable: true,
})
weather!: CourseWeatherDto | null;
}
@@ -0,0 +1,162 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDefined,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import {
FAILURE_CODES,
type FailureCode,
GENERATION_STAGES,
GENERATION_STATUSES,
type GenerationStage,
type GenerationStatus,
RATING_VALUES,
type RatingValue,
REQUEST_TEXT_MAX,
} from '../course-generation.constants';
import { CourseConditionsDto } from './course-conditions.dto';
import { CourseResultDto } from './course-result.dto';
/** POST /api/course-generations 요청 */
export class CreateCourseGenerationDto {
@ApiProperty({
description: `사용자가 쓴 문장(${REQUEST_TEXT_MAX}자 이내, 비어도 된다). 전화번호·이메일 모양은 가린 뒤 저장한다`,
example: '비 오는 토요일 오후, 강아지랑 수성못 데이트',
})
@IsString({ message: '문장은 문자열로 입력해 주세요.' })
@MaxLength(REQUEST_TEXT_MAX, {
message: `문장은 ${REQUEST_TEXT_MAX}자 이내로 입력해 주세요.`,
})
request!: string;
@ApiProperty({ description: '확인한 조건', type: CourseConditionsDto })
@IsDefined({ message: '조건을 보내 주세요.' })
@ValidateNested()
@Type(() => CourseConditionsDto)
conditions!: CourseConditionsDto;
@ApiPropertyOptional({
description: '다시 만들기라면 이전 생성 id(같은 기기의 생성만)',
})
@IsOptional()
@IsUUID('all', { message: '이전 생성 id 형식이 올바르지 않습니다.' })
previousGenerationId?: string;
}
/** POST /api/course-generations 응답(201) */
export class CourseGenerationCreatedDto {
@ApiProperty({ description: '생성 id — 진행 조회에 쓴다' })
id!: string;
@ApiProperty({ enum: GENERATION_STATUSES, example: 'queued' })
status!: GenerationStatus;
@ApiProperty({
description: '오늘 남은 횟수(결과가 나온 생성만 차감한다)',
example: 2,
})
remaining!: number;
}
export class CourseGenerationProgressDto {
@ApiProperty({ description: '찾은 후보 수', nullable: true, example: 128 })
found!: number | null;
@ApiProperty({
description: '거른 뒤 남은 후보 수',
nullable: true,
example: 31,
})
kept!: number | null;
}
export class CourseGenerationFailureDto {
@ApiProperty({
description:
'AI_002 AI 를 쓸 수 없음 · AI_003 조건에 맞는 장소 부족(문구는 에러 코드 사전)',
enum: FAILURE_CODES,
})
code!: FailureCode;
@ApiProperty({
description: '이유("대형견 동반이 확인된 실내 전시가 없어요")',
type: [String],
})
notices!: string[];
}
/** GET /api/course-generations/{id} 응답 — 생성이 실패해도 조회는 200 이다 */
export class CourseGenerationDto {
@ApiProperty()
id!: string;
@ApiProperty({ enum: GENERATION_STATUSES })
status!: GenerationStatus;
@ApiProperty({
description: '진행 단계 — 화면 문구는 앱이 언어별로 만든다(§3.1)',
enum: GENERATION_STAGES,
nullable: true,
})
stage!: GenerationStage | null;
@ApiProperty({ type: CourseGenerationProgressDto })
progress!: CourseGenerationProgressDto;
@ApiProperty({ type: CourseResultDto, nullable: true })
result!: CourseResultDto | null;
@ApiProperty({ type: CourseGenerationFailureDto, nullable: true })
failure!: CourseGenerationFailureDto | null;
@ApiProperty({ description: '오늘 남은 횟수', example: 1 })
remaining!: number;
@ApiProperty({
description: '내가 남긴 평가',
enum: RATING_VALUES,
nullable: true,
})
rating!: RatingValue | null;
}
/** GET /api/course-generations/quota 응답 — 입력 화면의 "오늘 2번 만들 수 있어요" */
export class CourseQuotaDto {
@ApiProperty({ description: '하루 횟수', example: 2 })
limit!: number;
@ApiProperty({ description: '오늘 쓴 횟수(결과가 나온 생성)', example: 0 })
used!: number;
@ApiProperty({ example: 2 })
remaining!: number;
@ApiProperty({
description: '다시 채워지는 시각(한국 시간 자정, ISO 8601 UTC)',
example: '2026-11-08T15:00:00.000Z',
})
resetsAt!: string;
@ApiProperty({
description: 'guest 비로그인 · member 로그인',
enum: ['guest', 'member'],
})
tier!: 'guest' | 'member';
}
/** PUT /api/course-generations/{id}/rating 요청 */
export class RateCourseGenerationDto {
@ApiProperty({
description: '"이 코스, 마음에 드세요?"',
enum: RATING_VALUES,
})
@IsIn(RATING_VALUES, { message: '평가는 like 또는 dislike 로 보내 주세요.' })
value!: RatingValue;
}
@@ -0,0 +1,317 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IMAGE_LICENSES,
PET_POLICIES,
PET_SIZES,
} from '@/modules/place/place.constants';
import type {
ImageLicense,
PetPolicy,
PetSize,
} from '@/modules/place/place.constants';
import type { ValueOrigin } from '@/modules/place/entities/place-listing.entity';
import { MOBILITIES, type Mobility } from '../planner/planner.constants';
import type { PlanNoticeCode, StopWarning } from '../planner/planner.types';
/** 기상청 단기예보 요약(§3.7) */
export class CourseWeatherDto {
@ApiProperty({ description: '날짜(YYYY-MM-DD)', example: '2026-11-14' })
date!: string;
@ApiProperty({
description: '시간 범위 중 가장 높은 강수확률(%)',
example: 60,
})
precipitationProbability!: number;
@ApiProperty({
description: '강수 형태(강수확률이 가장 높은 때)',
enum: ['none', 'rain', 'rain_snow', 'snow', 'shower'],
})
precipitationType!: string;
@ApiProperty({
description: '하늘 상태(가장 잦은 것)',
enum: ['clear', 'partly_cloudy', 'cloudy'],
nullable: true,
})
sky!: string | null;
@ApiProperty({ description: '최저 기온(℃)', nullable: true, example: 9 })
temperatureMin!: number | null;
@ApiProperty({ description: '최고 기온(℃)', nullable: true, example: 14 })
temperatureMax!: number | null;
@ApiProperty({
description: '발표 시각(ISO 8601 UTC)',
example: '2026-11-14T02:00:00.000Z',
})
issuedAt!: string;
}
/** 영업 구간 하나 — 'HH:mm', 자정을 넘기면 끝이 '24:00' 보다 크다('26:00' = 다음 날 02:00) */
export class CourseTimeRangeDto {
@ApiProperty({ example: '10:00' })
start!: string;
@ApiProperty({ example: '22:00' })
end!: string;
}
/** 화면에 보이는 사실 값 — 모두 DB 에서 붙인다(AI 가 지어낼 수 없다, §1) */
export class CourseFactsDto {
@ApiProperty({
description:
'그날 영업 구간. 쉬는 날이면 빈 배열, 정할 수 없으면 null(영업시간 미확인)',
type: [CourseTimeRangeDto],
nullable: true,
})
hoursOnDate!: CourseTimeRangeDto[] | null;
@ApiProperty({
description: '영업시간 원문(행사는 운영 시간 문구)',
nullable: true,
})
hoursText!: string | null;
@ApiProperty({
description:
'영업시간 출처(curation 확인한 곳 · public 공공데이터 · unknown 미확인)',
enum: ['curation', 'public', 'unknown'],
})
hoursOrigin!: ValueOrigin;
@ApiProperty({ description: '반려동물 동반', enum: PET_POLICIES })
petPolicy!: PetPolicy;
@ApiProperty({
description: '반려동물 정보 출처',
enum: ['curation', 'public', 'unknown'],
})
petOrigin!: ValueOrigin;
@ApiProperty({
description: '동반 가능 크기(확인한 곳만)',
enum: PET_SIZES,
isArray: true,
nullable: true,
})
petSizes!: PetSize[] | null;
@ApiProperty({ description: '운영자가 확인한 날(ISO 8601)', nullable: true })
verifiedAt!: string | null;
@ApiProperty({
description: '실내·외(큐레이션 값, 없으면 분류로 추정)',
enum: ['indoor', 'outdoor', 'unknown'],
})
setting!: 'indoor' | 'outdoor' | 'unknown';
@ApiProperty({
description: '행사 기간(YYYY-MM-DD, 장소는 null)',
nullable: true,
example: { start: '2026-10-27', end: '2026-11-22' },
})
eventPeriod!: { start: string; end: string } | null;
@ApiProperty({ description: '출처', example: '한국관광공사' })
provider!: string;
}
/** 장소 사이 이동(직선거리 추정, '약 N분') */
export class CourseLegDto {
@ApiProperty({ example: 10 })
minutes!: number;
@ApiProperty({ description: '직선거리(m)', example: 640 })
meters!: number;
@ApiProperty({
description: '실제 이동 수단(대중교통·자동차라도 가까우면 walk)',
enum: MOBILITIES,
})
mode!: Mobility;
@ApiProperty({
description: '추정값인가(경로 API 전에는 늘 true)',
example: true,
})
estimated!: boolean;
}
/** 사진 */
export class CoursePhotoDto {
@ApiProperty()
thumbUrl!: string;
@ApiProperty({ enum: IMAGE_LICENSES, nullable: true })
license!: ImageLicense | null;
}
/** 코스의 장소·행사 하나 */
export class CourseStopDto {
@ApiProperty({ enum: ['place', 'event'] })
kind!: 'place' | 'event';
@ApiProperty({ description: '장소 id 또는 행사 id' })
id!: string;
@ApiProperty({ example: '수성못' })
name!: string;
@ApiProperty({ description: '분류(행사는 event)', example: 'nature' })
category!: string;
@ApiProperty({
description: '행사 종류(장소는 null)',
nullable: true,
example: null,
})
eventKind!: string | null;
@ApiProperty({ type: CoursePhotoDto, nullable: true })
photo!: CoursePhotoDto | null;
@ApiProperty({
description: '도착(ISO 8601 UTC)',
example: '2026-11-14T05:00:00.000Z',
})
arrive!: string;
@ApiProperty({
description: '떠남(ISO 8601 UTC)',
example: '2026-11-14T06:00:00.000Z',
})
leave!: string;
@ApiProperty({ description: '머무는 시간(분)', example: 60 })
minutes!: number;
@ApiProperty({ description: '고른 이유(AI, 60자 이내)' })
reason!: string;
@ApiProperty({ type: CourseFactsDto })
facts!: CourseFactsDto;
@ApiProperty({
description:
'경고 — hours_unknown 영업시간 미확인 · event_time_unknown 행사 시간 확인 · pet_unknown 동반 정보 없음 · event_last_day 행사 마지막 날',
enum: [
'hours_unknown',
'event_time_unknown',
'pet_unknown',
'event_last_day',
],
isArray: true,
})
warnings!: StopWarning[];
@ApiProperty({ type: CourseLegDto, nullable: true })
legToNext!: CourseLegDto | null;
}
/** 바꾸기 후보 — 같은 시간대로 검증했고 앞뒤 이동도 다시 계산했다(앱이 AI 호출 없이 바꾼다) */
export class CourseAlternateDto extends CourseStopDto {
@ApiProperty({
description: '몇 번째 장소 대신인가(stops 의 0부터 자리)',
example: 1,
})
forIndex!: number;
@ApiProperty({ type: CourseLegDto, nullable: true })
legFromPrev!: CourseLegDto | null;
}
/** 코드가 남긴 알림 — 문구는 앱이 언어별로 만든다 */
export class CoursePlanNoticeDto {
@ApiProperty({
description:
'stop_removed 장소를 뺐음 · indoor_ratio_unmet 실내 후보가 모자람',
enum: ['stop_removed', 'indoor_ratio_unmet'],
})
code!: PlanNoticeCode;
@ApiProperty({ description: '뺀 장소 이름', nullable: true })
name!: string | null;
@ApiProperty({
description: '뺀 이유(closed · last_order · window · leg_too_long · pet)',
nullable: true,
})
cause!: string | null;
}
export class CourseWindowDto {
@ApiProperty({ example: '2026-11-14' })
date!: string;
@ApiProperty({ example: '13:00' })
start!: string;
@ApiProperty({ example: '19:00' })
end!: string;
}
export class CourseTotalsDto {
@ApiProperty({ example: 4 })
stops!: number;
@ApiProperty({ description: '첫 도착부터 마지막 떠남까지(분)', example: 330 })
totalMinutes!: number;
@ApiProperty({ description: '이동 시간 합계(분)', example: 35 })
travelMinutes!: number;
@ApiProperty({ description: '반려동물 동반이 확인된 곳 수', example: 0 })
petConfirmed!: number;
}
/** 생성 결과(§3.7) — 저장하기 전의 초안이고 사실 값은 생성 시점의 스냅샷이다 */
export class CourseResultDto {
@ApiProperty({
description:
'course 조건대로 · partial 일부만 맞춤(장소를 뺐거나 AI 가 알림을 남김)',
enum: ['course', 'partial'],
})
outcome!: 'course' | 'partial';
@ApiProperty({ description: '코스 이름(AI, 20자 이내)' })
title!: string;
@ApiProperty({ description: '요약(AI, 120자 이내)' })
summary!: string;
@ApiProperty({ description: 'AI 가 남긴 알림', type: [String] })
notices!: string[];
@ApiProperty({ type: [CoursePlanNoticeDto] })
planNotices!: CoursePlanNoticeDto[];
@ApiProperty({ type: CourseWeatherDto, nullable: true })
weather!: CourseWeatherDto | null;
@ApiProperty({ type: CourseWindowDto })
window!: CourseWindowDto;
@ApiProperty({ enum: MOBILITIES })
mobility!: Mobility;
@ApiProperty({ type: CourseTotalsDto })
totals!: CourseTotalsDto;
@ApiProperty({ type: [CourseStopDto] })
stops!: CourseStopDto[];
@ApiProperty({ type: [CourseAlternateDto] })
alternates!: CourseAlternateDto[];
@ApiProperty({
description: '출처 문구',
type: [String],
example: ['한국관광공사 TourAPI', '기상청 단기예보'],
})
sources!: string[];
}
@@ -0,0 +1,146 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
import { sqlIn } from '@/modules/place/place.constants';
import {
COURSE_LANGS,
type CourseLang,
FAILURE_CODES,
type FailureCode,
GENERATION_STAGES,
GENERATION_STATUSES,
type GenerationStage,
type GenerationStatus,
RATING_VALUES,
type RatingValue,
} from '../course-generation.constants';
import type { StoredConditions } from '../course-generation.types';
import type { CourseResultDto } from '../dto/course-result.dto';
/**
* AI 코스 생성 한 번(설계 docs/design/ai-course.md §6.1). 하루 횟수·비용도 이 테이블에서 센다(집계 테이블을 따로 두지 않는다).
* 요청 문장과 결과는 90일 보관한 뒤 문장은 지우고 지표만 남긴다(§10, 정리 작업은 출시 준비 단계).
*/
@Entity({ name: 'ai_course_generation' })
@Index('ix_ai_course_generation_device', ['deviceHash', 'createdAt'])
@Index('ix_ai_course_generation_user', ['userId', 'createdAt'])
@Index('ix_ai_course_generation_status', ['status'])
@Check('ck_ai_course_generation_status', sqlIn('status', GENERATION_STATUSES))
@Check('ck_ai_course_generation_stage', sqlIn('stage', GENERATION_STAGES, true))
@Check('ck_ai_course_generation_lang', sqlIn('lang', COURSE_LANGS))
@Check(
'ck_ai_course_generation_failure_code',
sqlIn('failure_code', FAILURE_CODES, true),
)
@Check('ck_ai_course_generation_rating', sqlIn('rating', RATING_VALUES, true))
export class AiCourseGeneration {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 로그인 사용자(인증 모듈이 생기면 FK). 비로그인이면 null */
@Column({ type: 'uuid', nullable: true })
userId!: string | null;
/** 기기 ID 의 SHA-256 해시 — 원문은 저장하지 않는다(§5) */
@Column({ type: 'text' })
deviceHash!: string;
@Column({ type: 'text', default: 'ko' })
lang!: CourseLang;
/** 사용자가 쓴 문장(전화번호·이메일 모양은 가린 뒤). 보관 기간이 지나면 지운다 */
@Column({ type: 'text', nullable: true })
requestText!: string | null;
/** 사용자가 확인한 조건(CourseConditions) */
@Column({ type: 'jsonb' })
conditions!: StoredConditions;
@Column({ type: 'text' })
status!: GenerationStatus;
@Column({ type: 'text', nullable: true })
stage!: GenerationStage | null;
/** 진행 표시 숫자 — 찾은 후보 · 거른 뒤 남은 후보 */
@Column({ type: 'integer', nullable: true })
foundCount!: number | null;
@Column({ type: 'integer', nullable: true })
keptCount!: number | null;
/** 결과 초안(§3.7). 실패해도 AI 가 남긴 알림은 failureNotices 에 둔다 */
@Column({ type: 'jsonb', nullable: true })
result!: CourseResultDto | null;
@Column({ type: 'text', nullable: true })
failureCode!: FailureCode | null;
/** 실패 이유(AI 가 남긴 알림 · 코드가 남긴 이유) */
@Column({ type: 'jsonb', nullable: true })
failureNotices!: string[] | null;
/** 하루 횟수를 차감했는지 — 결과가 나온 생성(succeeded)만 차감한다 */
@Column({ type: 'boolean', default: false })
counted!: boolean;
/** 다시 만들기라면 이전 생성 */
@Column({ type: 'uuid', nullable: true })
previousId!: string | null;
@Column({ type: 'text', nullable: true })
model!: string | null;
@Column({ type: 'text', nullable: true })
promptVersion!: string | null;
@Column({ type: 'integer', default: 0 })
inputTokens!: number;
@Column({ type: 'integer', default: 0 })
outputTokens!: number;
@Column({ type: 'integer', default: 0 })
cacheReadTokens!: number;
@Column({ type: 'integer', default: 0 })
cacheWriteTokens!: number;
/** 이 생성에 쓴 금액(토큰 × 가격 상수) — 하루 비용 상한을 센다 */
@Column({ type: 'double precision', default: 0 })
costUsd!: number;
/** 생성 시작부터 끝까지(ms) */
@Column({ type: 'integer', nullable: true })
latencyMs!: number | null;
@Column({ type: 'integer', default: 0 })
llmCalls!: number;
/** 거친 고치기 단계(품질 지표) */
@Column({ type: 'jsonb', default: () => `'[]'` })
repairs!: string[];
@Column({ type: 'text', nullable: true })
rating!: RatingValue | null;
@Column({ type: 'timestamptz', nullable: true })
ratedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
/** 작업을 시작한 시각 — 멈춘 작업을 찾는다 */
@Column({ type: 'timestamptz', nullable: true })
startedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
}
@@ -0,0 +1,87 @@
import {
buildGrid,
canStay,
hasUnknownHours,
openStateAt,
scheduleEnd,
stayAvailability,
} from './availability';
import { at, MON, place, SAT } from './planner.fixtures';
describe('openStateAt', () => {
it('영업시간이 없으면 미확인, 쉬는 요일이면 닫힘이다', () => {
expect(openStateAt(place({ hoursText: null }), SAT, at(15))).toBe(
'unknown',
);
const closedSaturday = place({ restText: '매주 토요일' });
expect(openStateAt(closedSaturday, SAT, at(15))).toBe('closed');
expect(openStateAt(closedSaturday, MON, at(15))).toBe('open');
});
});
describe('scheduleEnd', () => {
it('끝 시각 + 10분까지 끝나면 되고, 자정을 넘기지 않는다', () => {
expect(scheduleEnd({ end: at(20) })).toBe(at(20, 10));
expect(scheduleEnd({ end: at(23, 55) })).toBe(1440);
});
});
describe('stayAvailability', () => {
it('시간 범위 안에서 최소 머무는 시간만큼 이어서 열려 있어야 한다', () => {
const opensAtSeven = place({ hoursText: '19:00~23:00' });
// 14:00~19:40 범위에서는 19:00~19:40 의 40분만 열려 있다
expect(stayAvailability(opensAtSeven, SAT, at(14), at(19, 40), 40)).toBe(
'open',
);
expect(stayAvailability(opensAtSeven, SAT, at(14), at(19, 40), 50)).toBe(
'closed',
);
});
it('영업시간이 없으면 미확인이다', () => {
expect(
stayAvailability(place({ hoursText: null }), SAT, at(14), at(20), 50),
).toBe('unknown');
});
it('달을 적지 않은 하절기·동절기는 11월에 두 계절 시간이 다른 시각을 미확인으로 본다(수창청춘맨숀)', () => {
const mansion = place({
hoursText: '하절기 10:00~19:00동절기 10:00~18:00',
});
expect(stayAvailability(mansion, SAT, at(14), at(16), 50)).toBe('open');
// 18:00~19:00 은 하절기면 열고 동절기면 닫는다
expect(stayAvailability(mansion, SAT, at(18), at(20), 50)).toBe('unknown');
});
});
describe('buildGrid · canStay · hasUnknownHours', () => {
it('브레이크 시간에는 머물 수 없다', () => {
const restaurant = place({
category: 'food',
hoursText: '11:00~22:00 (브레이크타임 15:00~17:00)',
});
const grid = buildGrid(restaurant, SAT, at(14), at(20, 10));
expect(canStay(grid, at(14), at(15))).toBe(true);
expect(canStay(grid, at(14, 30), at(15, 30))).toBe(false);
expect(canStay(grid, at(17), at(18, 30))).toBe(true);
expect(hasUnknownHours(grid, at(14), at(15))).toBe(false);
});
it('미확인은 자연·관광지·행사에만 머물러도 되는 시간으로 본다', () => {
const park = place({ category: 'nature', hoursText: null });
const parkGrid = buildGrid(park, SAT, at(14), at(20, 10));
expect(canStay(parkGrid, at(14), at(16))).toBe(true);
expect(hasUnknownHours(parkGrid, at(14), at(16))).toBe(true);
const cafe = place({ category: 'cafe', hoursText: null });
expect(
canStay(buildGrid(cafe, SAT, at(14), at(20, 10)), at(14), at(15)),
).toBe(false);
});
it('격자 밖 시간은 머물 수 없다', () => {
const grid = buildGrid(place(), SAT, at(14), at(20, 10));
expect(canStay(grid, at(13, 50), at(15))).toBe(false);
expect(canStay(grid, at(19), at(20, 20))).toBe(false);
});
});
@@ -0,0 +1,135 @@
/**
* 영업 확인 — 그날 날짜·시각으로 isOpenAt 을 불러 머물 수 있는지 본다(설계 §3.3·§3.6).
* 브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간은 isOpenAt 이 이미 반영한다.
*/
import { isOpenAt } from '@/shared/utils/opening-hours';
import { allowsUnknownHours } from './candidate';
import {
DAY_END_MINUTES,
OPEN_CHECK_STEP_MINUTES,
TIME_STEP_MINUTES,
WINDOW_GRACE_MINUTES,
} from './planner.constants';
import type { Candidate, PlanContext } from './planner.types';
export type OpenState = 'open' | 'closed' | 'unknown';
/** 그날 한 시각의 영업 상태. 영업시간이 없거나 판단할 수 없으면 미확인 */
export function openStateAt(
c: Candidate,
date: string,
minute: number,
): OpenState {
if (!c.hours) return 'unknown';
const open = isOpenAt(c.hours, date, minute);
if (open === null) return 'unknown';
return open ? 'open' : 'closed';
}
/** 코스가 끝나야 하는 시각 — 끝 시각 + 10분(자정을 넘기지 않는다) */
export function scheduleEnd(ctx: Pick<PlanContext, 'end'>): number {
return Math.min(ctx.end + WINDOW_GRACE_MINUTES, DAY_END_MINUTES);
}
/**
* 거르기 — [from, to) 안에 최소 머무는 시간만큼 이어서 머물 수 있는 때가 있는가(10분 간격 확인).
* open: 영업이 확인된 때가 있다 · unknown: 미확인인 시각을 끼워야만 머물 수 있다 · closed: 머물 수 없다
*/
export function stayAvailability(
c: Candidate,
date: string,
from: number,
to: number,
minStay: number,
step = OPEN_CHECK_STEP_MINUTES,
): OpenState {
let openFrom: number | null = null;
let usableFrom: number | null = null;
let unknownFits = false;
for (let t = from; t < to; t += step) {
const state = openStateAt(c, date, t);
openFrom = state === 'open' ? (openFrom ?? t) : null;
usableFrom = state === 'closed' ? null : (usableFrom ?? t);
const covered = Math.min(t + step, to);
if (openFrom !== null && covered - openFrom >= minStay) return 'open';
if (usableFrom !== null && covered - usableFrom >= minStay) {
unknownFits = true;
}
}
return unknownFits ? 'unknown' : 'closed';
}
/**
* 코스 시간 범위를 5분 칸으로 나눠, 칸마다 머물러도 되는지와 영업 여부가 미확인인지를 누적 합으로 둔다.
* 머물러도 되는 칸 = 영업 중, 또는 미확인을 허용하는 분류(자연·관광지·행사)의 미확인.
* 시간표를 고칠 때 같은 장소를 수백 번 확인하므로 장소마다 한 번만 계산한다.
*/
export interface AvailabilityGrid {
/** 첫 칸 시각(분) */
from: number;
/** 마지막 칸이 끝나는 시각(분) */
to: number;
step: number;
/** allowed[i] = 앞 i 칸 중 머물러도 되는 칸 수 */
allowed: number[];
/** unknown[i] = 앞 i 칸 중 미확인 칸 수 */
unknown: number[];
}
export function buildGrid(
c: Candidate,
date: string,
from: number,
to: number,
step = TIME_STEP_MINUTES,
): AvailabilityGrid {
const slots = Math.max(0, Math.floor((to - from) / step));
const unknownOk = allowsUnknownHours(c);
const allowed = [0];
const unknown = [0];
for (let i = 0; i < slots; i++) {
const state = openStateAt(c, date, from + i * step);
const usable = state === 'open' || (state === 'unknown' && unknownOk);
allowed.push(allowed[i] + (usable ? 1 : 0));
unknown.push(unknown[i] + (state === 'unknown' ? 1 : 0));
}
return { from, to: from + slots * step, step, allowed, unknown };
}
/** [start, end) 를 덮는 칸 범위. 격자 밖이면 null */
function slotRange(
grid: AvailabilityGrid,
start: number,
end: number,
): [number, number] | null {
if (start < grid.from || end > grid.to || end <= start) return null;
return [
Math.floor((start - grid.from) / grid.step),
Math.ceil((end - grid.from) / grid.step),
];
}
/** [start, end) 동안 내내 머물러도 되는가 */
export function canStay(
grid: AvailabilityGrid,
start: number,
end: number,
): boolean {
const range = slotRange(grid, start, end);
if (!range) return false;
const [a, b] = range;
return grid.allowed[b] - grid.allowed[a] === b - a;
}
/** [start, end) 중에 영업 여부가 미확인인 때가 있는가 */
export function hasUnknownHours(
grid: AvailabilityGrid,
start: number,
end: number,
): boolean {
const range = slotRange(grid, start, end);
if (!range) return false;
const [a, b] = range;
return grid.unknown[b] - grid.unknown[a] > 0;
}
@@ -0,0 +1,129 @@
import {
allowsUnknownHours,
diversityKey,
isEligible,
petExclusion,
prefersIndoor,
settingOf,
stayRange,
} from './candidate';
import { event, place } from './planner.fixtures';
import type { PetCondition } from './planner.types';
const smallDog: PetCondition = { kind: 'dog', size: 'small', count: 1 };
describe('isEligible', () => {
it('AI 순위가 있는 장소와 공연이 아닌 행사만 후보다', () => {
expect(isEligible(place())).toBe(true);
expect(isEligible(place({ tier: null }))).toBe(false);
expect(isEligible(place({ tier: 1, category: 'lodging' }))).toBe(false);
expect(isEligible(event({ eventKind: 'exhibition' }))).toBe(true);
expect(isEligible(event({ eventKind: 'performance' }))).toBe(false);
});
});
describe('stayRange', () => {
it('분류별 머무는 시간 범위를 쓰고, 행사는 60~150분이다', () => {
expect(stayRange(place({ category: 'cafe' }))).toEqual({
min: 40,
max: 90,
});
expect(stayRange(place({ category: 'food' }))).toEqual({
min: 60,
max: 100,
});
expect(stayRange(event({ eventKind: 'exhibition' }))).toEqual({
min: 60,
max: 150,
});
});
});
describe('settingOf', () => {
it('큐레이션 값이 없으면 분류로 추정한다(문화시설·식당·카페는 실내, 자연은 실외, 관광지는 미확인)', () => {
expect(settingOf(place({ category: 'culture' }))).toBe('indoor');
expect(settingOf(place({ category: 'cafe' }))).toBe('indoor');
expect(settingOf(place({ category: 'nature' }))).toBe('outdoor');
expect(settingOf(place({ category: 'attraction' }))).toBe('unknown');
});
it('큐레이션 값을 먼저 쓰고, 실내·외가 함께 있는 곳은 실내로 본다', () => {
expect(settingOf(place({ category: 'cafe', setting: 'outdoor' }))).toBe(
'outdoor',
);
expect(settingOf(place({ category: 'nature', setting: 'both' }))).toBe(
'indoor',
);
});
it('행사 중 전시는 실내, 축제·행사는 미확인이다', () => {
expect(settingOf(event({ eventKind: 'exhibition' }))).toBe('indoor');
expect(settingOf(event({ eventKind: 'festival' }))).toBe('unknown');
});
});
describe('diversityKey', () => {
it('전시 행사는 문화시설로 센다', () => {
expect(diversityKey(event({ eventKind: 'exhibition' }))).toBe('culture');
expect(diversityKey(event({ eventKind: 'festival' }))).toBe('event');
expect(diversityKey(place({ category: 'cafe' }))).toBe('cafe');
});
});
describe('allowsUnknownHours', () => {
it('영업시간이 미확인이어도 자연·관광지와 행사는 남긴다', () => {
expect(allowsUnknownHours(place({ category: 'nature' }))).toBe(true);
expect(allowsUnknownHours(place({ category: 'attraction' }))).toBe(true);
expect(allowsUnknownHours(event())).toBe(true);
expect(allowsUnknownHours(place({ category: 'cafe' }))).toBe(false);
expect(allowsUnknownHours(place({ category: 'culture' }))).toBe(false);
});
});
describe('petExclusion', () => {
it('반려동물 조건이 없으면 거르지 않는다', () => {
expect(
petExclusion(place({ category: 'cafe', petPolicy: 'not_allowed' }), null),
).toBeNull();
});
it('동반 불가와 크기 불일치는 뺀다', () => {
expect(petExclusion(place({ petPolicy: 'not_allowed' }), smallDog)).toBe(
'pet_not_allowed',
);
const largeOnlyCafe = place({
category: 'cafe',
petPolicy: 'allowed',
petSizes: ['medium', 'large'],
});
expect(petExclusion(largeOnlyCafe, smallDog)).toBe('pet_size');
// 크기를 모르면 크기로는 거르지 않는다
expect(
petExclusion(largeOnlyCafe, { kind: 'dog', size: null, count: 1 }),
).toBeNull();
});
it('실내는 동반이 확인된 곳만 남기고, 야외·축제는 미확인도 남긴다', () => {
expect(petExclusion(place({ category: 'cafe' }), smallDog)).toBe(
'pet_indoor_unknown',
);
expect(
petExclusion(place({ category: 'cafe', petPolicy: 'partial' }), smallDog),
).toBeNull();
expect(petExclusion(place({ category: 'nature' }), smallDog)).toBeNull();
expect(petExclusion(event({ eventKind: 'festival' }), smallDog)).toBeNull();
expect(petExclusion(event({ eventKind: 'exhibition' }), smallDog)).toBe(
'pet_indoor_unknown',
);
});
});
describe('prefersIndoor', () => {
it('강수확률 40% 이상이고 비 오면 실내를 골랐을 때만 실내 위주다', () => {
expect(prefersIndoor(60, true)).toBe(true);
expect(prefersIndoor(40, true)).toBe(true);
expect(prefersIndoor(39, true)).toBe(false);
expect(prefersIndoor(80, false)).toBe(false);
expect(prefersIndoor(null, true)).toBe(false);
});
});
@@ -0,0 +1,112 @@
/**
* 후보 하나를 보는 규칙 — 분류·실내외·머무는 시간·반려동물(설계 §3.3·§3.6).
*/
import {
type CandidateCategory,
EXCLUDED_EVENT_KINDS,
HOURS_UNKNOWN_ALLOWED,
INDOOR_CATEGORIES,
OUTDOOR_CATEGORIES,
RAIN_PROBABILITY_MIN,
STAY_MINUTES,
type StayRange,
} from './planner.constants';
import type { Candidate, PetCondition } from './planner.types';
/** 시간표에서 쓰는 분류 — 행사는 'event'. 숙박은 후보가 아니라 거르기에서 빠진다 */
export function candidateCategory(c: Candidate): CandidateCategory {
if (c.kind === 'event' || c.category === 'event') return 'event';
return c.category === 'lodging' ? 'other' : c.category;
}
/** AI 후보가 될 수 있는가 — 장소는 AI 순위(1·2)가 있고 숙박이 아니어야 하고, 행사는 공연이 아니어야 한다 */
export function isEligible(c: Candidate): boolean {
if (c.kind === 'event') {
return c.eventKind !== null && !EXCLUDED_EVENT_KINDS.includes(c.eventKind);
}
return c.tier !== null && c.category !== 'lodging' && c.category !== 'event';
}
/** 분류별 머무는 시간 범위(분) */
export function stayRange(c: Candidate): StayRange {
return STAY_MINUTES[candidateCategory(c)];
}
export type SettingClass = 'indoor' | 'outdoor' | 'unknown';
/**
* 실내·실외 — 큐레이션 값을 쓰고, 없으면 분류로 추정한다(§3.3).
* 실내·외가 함께 있는 곳(both)은 실내로 본다 — 비를 피할 수 있고, 반려동물은 실내 규칙을 따라야 해서다.
*/
export function settingOf(c: Candidate): SettingClass {
if (c.setting === 'indoor' || c.setting === 'both') return 'indoor';
if (c.setting === 'outdoor') return 'outdoor';
if (c.kind === 'event') {
return c.eventKind === 'exhibition' ? 'indoor' : 'unknown';
}
const category = candidateCategory(c);
if (INDOOR_CATEGORIES.includes(category)) return 'indoor';
if (OUTDOOR_CATEGORIES.includes(category)) return 'outdoor';
return 'unknown';
}
export function isIndoor(c: Candidate): boolean {
return settingOf(c) === 'indoor';
}
/** 다양성을 셀 때의 분류 — 전시 행사는 문화시설로 센다(평가셋 must_include 와 같다) */
export function diversityKey(c: Candidate): string {
if (c.kind === 'event') {
return c.eventKind === 'exhibition' ? 'culture' : 'event';
}
return candidateCategory(c);
}
/** 영업시간이 미확인이어도 남기는가 — 자연·관광지와 행사(§13) */
export function allowsUnknownHours(c: Candidate): boolean {
return (
c.kind === 'event' || HOURS_UNKNOWN_ALLOWED.includes(candidateCategory(c))
);
}
export type PetExclusion =
| 'pet_not_allowed'
| 'pet_size'
| 'pet_indoor_unknown';
/**
* 반려동물 조건으로 빼야 하는 이유(§3.3·§13). 빼지 않으면 null.
* 동반 불가와 크기 불일치는 뺀다. 실내는 동반이 확인된 곳(가능·일부 가능)만 남기고,
* 야외 분류와 축제·행사는 미확인도 남긴다(결과에 '동반 정보 없음' 경고).
*/
export function petExclusion(
c: Candidate,
pet: PetCondition | null,
): PetExclusion | null {
if (!pet) return null;
if (c.petPolicy === 'not_allowed') return 'pet_not_allowed';
if (pet.size && c.petSizes && !c.petSizes.includes(pet.size)) {
return 'pet_size';
}
if (c.petPolicy === 'unknown' && settingOf(c) === 'indoor') {
return 'pet_indoor_unknown';
}
return null;
}
/** 반려동물 동반이 확인된 곳(가능·일부 가능) */
export function isPetConfirmed(c: Candidate): boolean {
return c.petPolicy === 'allowed' || c.petPolicy === 'partial';
}
/** 비 조건 — 강수확률이 40% 이상이고 사용자가 '비 오면 실내'를 골랐을 때. 예보가 없으면 쓰지 않는다 */
export function prefersIndoor(
precipitationProbability: number | null,
indoorIfRain: boolean,
): boolean {
return (
indoorIfRain &&
precipitationProbability !== null &&
precipitationProbability >= RAIN_PROBABILITY_MIN
);
}
@@ -0,0 +1,120 @@
import { exclusionReason, filterCandidates } from './filter';
import { context, event, place } from './planner.fixtures';
import type { PetCondition } from './planner.types';
const smallDog: PetCondition = { kind: 'dog', size: 'small', count: 1 };
describe('exclusionReason', () => {
const ctx = context();
it('영업 중인 관광공사 장소는 남긴다', () => {
expect(
exclusionReason(place({ category: 'cafe', east: 300 }), ctx),
).toBeNull();
});
it('후보가 아닌 장소(순위 없음·숙박)와 공연은 뺀다', () => {
expect(exclusionReason(place({ tier: null }), ctx)).toBe('not_candidate');
expect(exclusionReason(place({ tier: 1, category: 'lodging' }), ctx)).toBe(
'not_candidate',
);
expect(exclusionReason(event({ eventKind: 'performance' }), ctx)).toBe(
'not_candidate',
);
});
it('이동 수단별 반경 밖은 뺀다(걸어서 1.5km · 대중교통 6km)', () => {
const far = place({ east: 1600 });
expect(exclusionReason(far, ctx)).toBe('out_of_radius');
expect(exclusionReason(far, context({ mobility: 'transit' }))).toBeNull();
});
it('그날 진행하지 않는 행사는 뺀다', () => {
expect(
exclusionReason(
event({ startDate: '2026-10-27', endDate: '2026-11-06' }),
ctx,
),
).toBe('event_not_running');
expect(
exclusionReason(
event({ startDate: '2026-11-07', endDate: '2026-11-07' }),
ctx,
),
).toBeNull();
});
it('그날 공휴일의 휴무 표시가 있으면 뺀다(설·추석은 연휴 내내, 공휴일 휴무는 모든 공휴일)', () => {
const museum = place({ restText: '매주 월요일 / 설날·추석 당일' });
expect(exclusionReason(museum, context({ holiday: 'seollal' }))).toBe(
'holiday',
);
// 어린이날에는 설·추석 휴무인 곳을 빼지 않는다
expect(exclusionReason(museum, context({ holiday: 'public' }))).toBeNull();
expect(exclusionReason(museum, ctx)).toBeNull();
const closedOnHolidays = place({ restText: '매주 월요일 / 공휴일' });
expect(
exclusionReason(closedOnHolidays, context({ holiday: 'public' })),
).toBe('holiday');
});
it('반려동물 조건이면 실내 미확인·동반 불가·크기 불일치를 빼고 야외 미확인은 남긴다', () => {
const petCtx = context({ pet: smallDog });
expect(exclusionReason(place({ category: 'cafe' }), petCtx)).toBe(
'pet_indoor_unknown',
);
expect(
exclusionReason(
place({ category: 'cafe', petPolicy: 'not_allowed' }),
petCtx,
),
).toBe('pet_not_allowed');
expect(
exclusionReason(
place({ category: 'cafe', petPolicy: 'allowed', petSizes: ['large'] }),
petCtx,
),
).toBe('pet_size');
expect(exclusionReason(place({ category: 'nature' }), petCtx)).toBeNull();
});
it('시간 범위 안에 최소 머무는 시간만큼 열지 않으면 뺀다', () => {
expect(exclusionReason(place({ restText: '매주 토요일' }), ctx)).toBe(
'closed',
);
// 행사 시간이 범위(14:00~20:00)와 겹치지 않는다
expect(exclusionReason(event({ hoursText: '10:00~13:00' }), ctx)).toBe(
'closed',
);
});
it('영업시간 미확인은 자연·관광지와 행사만 남긴다', () => {
expect(
exclusionReason(place({ category: 'cafe', hoursText: null }), ctx),
).toBe('hours_unknown');
expect(
exclusionReason(place({ category: 'culture', hoursText: null }), ctx),
).toBe('hours_unknown');
expect(
exclusionReason(place({ category: 'nature', hoursText: null }), ctx),
).toBeNull();
expect(exclusionReason(event({ hoursText: null }), ctx)).toBeNull();
});
});
describe('filterCandidates', () => {
it('남긴 후보와 뺀 이유별 개수를 돌려준다', () => {
const kept = place();
const result = filterCandidates(
[
kept,
place({ east: 2000 }),
place({ east: 1800 }),
place({ restText: '매주 토요일' }),
],
context(),
);
expect(result.kept).toEqual([kept]);
expect(result.excluded).toEqual({ out_of_radius: 2, closed: 1 });
});
});
@@ -0,0 +1,94 @@
/**
* 후보 거르기(설계 §3.3) — 반경·행사 기간·공휴일·반려동물·영업을 코드로 본다.
* 비는 거르지 않는다(시간표 검증에서 실내 비율을 본다).
*/
import { distanceMeters } from '@/shared/utils/geo';
import { closedOnHoliday } from '@/shared/utils/kr-holidays';
import { scheduleEnd, stayAvailability } from './availability';
import {
allowsUnknownHours,
isEligible,
type PetExclusion,
petExclusion,
stayRange,
} from './candidate';
import { SEARCH_RADIUS_M } from './planner.constants';
import type { Candidate, PlanContext } from './planner.types';
export type ExcludeReason =
| 'not_candidate'
| 'out_of_radius'
| 'event_not_running'
| 'holiday'
| PetExclusion
| 'closed'
| 'hours_unknown';
export interface FilterResult {
kept: Candidate[];
/** 뺀 이유별 개수 — 진행 표시와 코스를 못 만든 이유에 쓴다 */
excluded: Partial<Record<ExcludeReason, number>>;
}
/** 그날 진행하는 행사인가('YYYY-MM-DD' 는 글자 순서가 날짜 순서와 같다) */
function isRunningOn(c: Candidate, date: string): boolean {
return (
c.startDate !== null &&
c.endDate !== null &&
c.startDate <= date &&
date <= c.endDate
);
}
/**
* 후보에서 빼야 하는 이유. 남기면 null. 비싼 영업 확인을 마지막에 한다.
* - 영업: 시간 범위 안에서 분류별 최소 머무는 시간만큼 열려 있어야 한다
* - 영업시간 미확인: 미확인인 시각을 끼워야만 머물 수 있으면, 자연·관광지와 행사만 남긴다
* - 공휴일: 그날이 공휴일·명절이고 그 공휴일의 휴무 표시가 있으면 뺀다('공휴일' 휴무는 모든 공휴일, 설·추석은 연휴 내내)
*/
export function exclusionReason(
c: Candidate,
ctx: PlanContext,
): ExcludeReason | null {
if (!isEligible(c)) return 'not_candidate';
if (distanceMeters(ctx.center, c) > SEARCH_RADIUS_M[ctx.mobility]) {
return 'out_of_radius';
}
if (c.kind === 'event' && !isRunningOn(c, ctx.date)) {
return 'event_not_running';
}
if (
ctx.holiday &&
c.hours &&
closedOnHoliday(c.hours.holidayClosures, ctx.holiday)
) {
return 'holiday';
}
const pet = petExclusion(c, ctx.pet);
if (pet) return pet;
const state = stayAvailability(
c,
ctx.date,
ctx.start,
scheduleEnd(ctx),
stayRange(c).min,
);
if (state === 'closed') return 'closed';
if (state === 'unknown' && !allowsUnknownHours(c)) return 'hours_unknown';
return null;
}
export function filterCandidates(
candidates: Candidate[],
ctx: PlanContext,
): FilterResult {
const kept: Candidate[] = [];
const excluded: Partial<Record<ExcludeReason, number>> = {};
for (const c of candidates) {
const reason = exclusionReason(c, ctx);
if (reason === null) kept.push(c);
else excluded[reason] = (excluded[reason] ?? 0) + 1;
}
return { kept, excluded };
}
@@ -0,0 +1,382 @@
import { planCourse } from './plan-course';
import { at, context, event, place, SAT } from './planner.fixtures';
import type {
Candidate,
CourseDraft,
DraftAlternate,
DraftStop,
PlanResult,
ShortlistEntry,
} from './planner.types';
/** 후보마다 순서대로 P01·P02… 번호를 붙인 목록(행사는 E01…) */
function shortlist(...candidates: Candidate[]): ShortlistEntry[] {
let p = 0;
let e = 0;
return candidates.map((candidate) => {
const isEvent = candidate.kind === 'event';
const n = isEvent ? ++e : ++p;
return {
ref: `${isEvent ? 'E' : 'P'}${String(n).padStart(2, '0')}`,
candidate,
distanceM: 0,
};
});
}
function draft(
stops: [string, number][],
alternates: DraftAlternate[] = [],
notices: string[] = [],
): CourseDraft {
return {
stops: stops.map(
([ref, minutes]): DraftStop => ({ ref, minutes, reason: `${ref} 이유` }),
),
alternates,
notices,
};
}
function expectCourse(result: PlanResult) {
if (result.outcome === 'failed') {
throw new Error(`코스를 만들지 못했다: ${JSON.stringify(result)}`);
}
return result;
}
const cafe = (east = 0, north = 0) =>
place({ category: 'cafe', east, north, hoursText: '10:00~22:00' });
const museum = (east = 0, north = 0, hoursText = '09:00~18:00') =>
place({ category: 'culture', east, north, hoursText });
const park = (east = 0, north = 0) =>
place({
category: 'nature',
east,
north,
hoursText: null,
setting: 'outdoor',
});
describe('planCourse', () => {
it('AI 순서대로 위반 없이 짜지면 그대로 내고 이동·합계를 붙인다', () => {
const list = shortlist(
cafe(),
museum(300),
place({ category: 'food', east: 600, hoursText: '11:00~22:00' }),
);
const result = expectCourse(
planCourse(
draft([
['P01', 70],
['P02', 120],
['P03', 90],
]),
list,
context(),
),
);
expect(result.outcome).toBe('course');
expect(result.repairs).toEqual([]);
expect(result.softViolations).toEqual([]);
expect(result.stops.map((s) => [s.ref, s.arrive, s.leave])).toEqual([
['P01', at(14), at(15, 10)],
['P02', at(15, 20), at(17, 20)],
['P03', at(17, 30), at(19)],
]);
expect(result.stops[0].legToNext).toEqual({
minutes: 10,
meters: 300,
mode: 'walk',
});
expect(result.stops[2].legToNext).toBeNull();
expect(result.stops[1].reason).toBe('P02 이유');
expect(result.totals).toEqual({
stops: 3,
totalMinutes: 300,
travelMinutes: 20,
petConfirmed: 0,
});
});
it('바꾸기 후보를 같은 시간대에 넣어 검증하고, 맞지 않는 후보는 버린다', () => {
const list = shortlist(cafe(), museum(300), cafe(0, 300), cafe(2000));
const result = expectCourse(
planCourse(
draft(
[
['P01', 70],
['P02', 120],
],
[
{ forStop: 1, ref: 'P03', reason: '다른 카페' },
// 박물관에서 2km 라 걸어서 15분을 넘는다
{ forStop: 2, ref: 'P04', reason: '먼 카페' },
],
),
list,
context(),
),
);
expect(result.alternates).toHaveLength(1);
const [alt] = result.alternates;
expect(alt).toMatchObject({
forRef: 'P01',
ref: 'P03',
arrive: at(14),
// 박물관 도착(15:20) - 이동 10분 = 15:10 까지
leave: at(15, 10),
legFromPrev: null,
reason: '다른 카페',
});
expect(alt.legToNext?.minutes).toBe(10);
});
it('목록 밖 번호와 중복은 버린다', () => {
const list = shortlist(cafe(), museum(300));
const result = expectCourse(
planCourse(
draft([
['P01', 60],
['P99', 60],
['P01', 60],
['P02', 60],
]),
list,
context(),
),
);
expect(result.stops.map((s) => s.ref)).toEqual(['P01', 'P02']);
expect(result.repairs).toEqual(['ref_dropped']);
});
it('AI 가 장소를 고르지 않았으면 no_course, 쓸 수 있는 장소가 2곳 미만이면 unrepairable', () => {
const list = shortlist(cafe(), museum(300));
expect(planCourse(draft([]), list, context())).toEqual({
outcome: 'failed',
cause: 'no_course',
violations: [],
repairs: [],
});
const one = planCourse(
draft([
['P01', 60],
['X01', 60],
]),
list,
context(),
);
expect(one).toMatchObject({
outcome: 'failed',
cause: 'unrepairable',
violations: [
{ code: 'unknown_ref', ref: 'X01' },
{ code: 'too_few_stops' },
],
});
});
it('일찍 닫는 곳이 뒤에 있으면 순서를 바꾼다', () => {
const list = shortlist(cafe(), park(300), museum(0, 300, '09:00~16:00'));
const result = expectCourse(
planCourse(
draft([
['P01', 60],
['P02', 60],
['P03', 60],
]),
list,
context({ end: at(18) }),
),
);
expect(result.stops.map((s) => s.ref)).toEqual(['P01', 'P03', 'P02']);
expect(result.repairs).toContain('reordered');
expect(result.outcome).toBe('course');
});
it('끝 시각을 넘기면 머무는 시간을 줄인다', () => {
const list = shortlist(museum(), cafe());
const result = expectCourse(
planCourse(
draft([
['P01', 120],
['P02', 90],
]),
list,
context({ end: at(17) }),
),
);
expect(result.repairs).toEqual(['durations']);
expect(result.stops[1].leave).toBeLessThanOrEqual(at(17, 10));
});
it('그날 열지 않는 곳은 바꾸기 후보로 바꾼다', () => {
const closed = museum(300, 0);
closed.hours = place({ restText: '매주 토요일' }).hours;
const list = shortlist(cafe(), closed, museum(0, 300));
const result = expectCourse(
planCourse(
draft(
[
['P01', 60],
['P02', 90],
],
[{ forStop: 2, ref: 'P03', reason: '대신 갈 박물관' }],
),
list,
context(),
),
);
expect(result.stops.map((s) => [s.ref, s.reason])).toEqual([
['P01', 'P01 이유'],
['P03', '대신 갈 박물관'],
]);
expect(result.repairs).toEqual(['replaced']);
expect(result.outcome).toBe('course');
expect(result.alternates).toEqual([]);
});
it('바꿀 후보가 없으면 빼고 이유를 알린다(partial)', () => {
const closed = museum(300, 0);
closed.hours = place({ restText: '매주 토요일' }).hours;
const list = shortlist(cafe(), closed, park(0, 300));
const result = expectCourse(
planCourse(
draft([
['P01', 60],
['P02', 90],
['P03', 60],
]),
list,
context(),
),
);
expect(result.stops.map((s) => s.ref)).toEqual(['P01', 'P03']);
expect(result.outcome).toBe('partial');
expect(result.notices).toEqual([
{ code: 'stop_removed', ref: 'P02', cause: 'closed' },
]);
expect(result.repairs).toContain('removed');
});
it('고쳐도 2곳을 채우지 못하면 위반 목록과 함께 unrepairable', () => {
const closed = place({ category: 'culture', restText: '매주 토요일' });
const list = shortlist(cafe(), closed);
const result = planCourse(
draft([
['P01', 60],
['P02', 60],
]),
list,
context(),
);
expect(result).toMatchObject({
outcome: 'failed',
cause: 'unrepairable',
violations: [{ code: 'closed', ref: 'P02' }],
});
});
it('비 조건이면 실외 장소를 실내 바꾸기 후보로 바꿔 실내 비율 50% 를 맞춘다', () => {
const list = shortlist(
park(),
place({ category: 'attraction', east: 300 }),
cafe(600),
museum(0, 300),
);
const result = expectCourse(
planCourse(
draft(
[
['P01', 60],
['P02', 60],
['P03', 60],
],
[{ forStop: 1, ref: 'P04', reason: '비 오면 박물관' }],
),
list,
context({ preferIndoor: true }),
),
);
expect(result.stops.map((s) => s.ref)).toEqual(['P04', 'P02', 'P03']);
expect(result.repairs).toEqual(['indoor_swap']);
expect(result.notices).toEqual([]);
});
it('실내 후보가 모자라면 코스는 내고 알린다', () => {
const list = shortlist(
park(),
place({ category: 'attraction', east: 300 }),
cafe(600),
);
const result = expectCourse(
planCourse(
draft([
['P01', 60],
['P02', 60],
['P03', 60],
]),
list,
context({ preferIndoor: true }),
),
);
expect(result.outcome).toBe('course');
expect(result.notices).toEqual([{ code: 'indoor_ratio_unmet' }]);
});
it('미확인 영업시간·동반 정보·행사 마지막 날을 경고로 붙인다', () => {
const list = shortlist(
park(),
event({ east: 300, hoursText: null, endDate: SAT }),
);
const result = expectCourse(
planCourse(
draft([
['P01', 60],
['E01', 90],
]),
list,
context({ pet: { kind: 'dog', size: 'small', count: 1 } }),
),
);
expect(result.stops.map((s) => s.warnings)).toEqual([
['hours_unknown', 'pet_unknown'],
['event_time_unknown', 'pet_unknown', 'event_last_day'],
]);
});
it('AI 가 알림을 남겼으면 partial 이다', () => {
const list = shortlist(park(), park(300));
const result = expectCourse(
planCourse(
draft(
[
['P01', 60],
['P02', 60],
],
[],
['반려동물 동반이 확인된 카페가 없어 산책 위주로 만들었어요'],
),
list,
context(),
),
);
expect(result.outcome).toBe('partial');
expect(result.softViolations).toEqual([]);
});
it('후보에 다른 분류가 있는데 한 분류만 고르면 다양성 약한 위반으로 남긴다', () => {
const list = shortlist(cafe(), cafe(300), museum(600));
const result = expectCourse(
planCourse(
draft([
['P01', 60],
['P02', 60],
]),
list,
context(),
),
);
expect(result.softViolations).toEqual([{ code: 'diversity' }]);
});
});
@@ -0,0 +1,489 @@
/**
* AI 가 고른 장소로 시간표를 짜고, 검증하고, 고친다(설계 §3.6). 바꾸기 후보도 같은 시간대로 검증해 둔다(§3.7).
* AI 재호출(고치기 4)은 생성 작업이 한다 — 여기서는 고칠 수 없으면 위반 목록과 함께 failed 를 돌려준다.
*/
import { canStay } from './availability';
import { diversityKey, isIndoor, isPetConfirmed, stayRange } from './candidate';
import {
RAIN_INDOOR_RATIO_MIN,
STOPS_MAX,
STOPS_MIN,
TIME_STEP_MINUTES,
} from './planner.constants';
import type {
Candidate,
CourseDraft,
DraftStop,
PlanContext,
PlannedAlternate,
PlannedStop,
PlanNotice,
PlanResult,
PlanTotals,
RepairStep,
ShortlistEntry,
Violation,
ViolationCode,
} from './planner.types';
import {
arrivesBeforeLastOrder,
chain,
fitMinutes,
isBetterChoice,
isHardViolation,
isReachable,
mealsOk,
type OrderChoice,
type PlanItem,
PlanEngine,
type SchedulePlan,
searchOrders,
stopWarnings,
timelineViolations,
} from './schedule';
import { isLegWithinLimit } from './travel';
/** 고친 단계를 순서대로, 한 번씩만 모은다 */
class RepairLog {
private readonly steps: RepairStep[] = [];
add(step: RepairStep): void {
if (!this.steps.includes(step)) this.steps.push(step);
}
list(): RepairStep[] {
return [...this.steps];
}
}
/**
* AI 가 고른 번호를 시간표 항목으로 바꾼다. 목록 밖 번호·중복은 버리고, 6곳을 넘으면 앞의 6곳만 쓴다.
*/
function toPlanItems(
stops: DraftStop[],
byRef: Map<string, Candidate>,
): { items: PlanItem[]; violations: Violation[] } {
const items: PlanItem[] = [];
const violations: Violation[] = [];
for (const stop of stops) {
const candidate = byRef.get(stop.ref);
if (!candidate) {
violations.push({ code: 'unknown_ref', ref: stop.ref });
} else if (items.some((item) => item.ref === stop.ref)) {
violations.push({ code: 'duplicate_ref', ref: stop.ref });
} else if (items.length >= STOPS_MAX) {
violations.push({ code: 'too_many_stops', ref: stop.ref });
} else {
items.push({
ref: stop.ref,
candidate,
desired: fitMinutes(candidate, stop.minutes),
reason: stop.reason,
aiIndex: items.length,
});
}
}
return { items, violations };
}
/** 장소별 바꾸기 후보(목록 안 번호, 장소마다 첫 번째 하나) — 키는 바꿀 장소의 번호 */
function alternatesByStop(
draft: CourseDraft,
byRef: Map<string, Candidate>,
items: PlanItem[],
): Map<string, PlanItem> {
const out = new Map<string, PlanItem>();
for (const alt of draft.alternates) {
const target = draft.stops[alt.forStop - 1]?.ref;
const item = items.find((it) => it.ref === target);
const candidate = byRef.get(alt.ref);
if (!item || !candidate || out.has(item.ref)) continue;
if (items.some((it) => it.ref === alt.ref)) continue;
out.set(item.ref, {
ref: alt.ref,
candidate,
desired: fitMinutes(candidate, item.desired),
reason: alt.reason,
aiIndex: item.aiIndex,
});
}
return out;
}
/**
* 교체·제외 대상 — 위반한 장소(이동이 길면 양 끝 장소). 끝 시각을 넘기거나 짚이는 장소가 없으면
* 나머지 장소도 뒤에서부터 넣는다.
*/
function repairTargets(items: PlanItem[], violations: Violation[]): string[] {
const targets: string[] = [];
const add = (ref: string | undefined) => {
if (ref && !targets.includes(ref)) targets.push(ref);
};
for (const v of violations) {
if (v.code === 'window') continue;
add(v.ref);
if (v.code === 'leg_too_long') {
add(items[items.findIndex((it) => it.ref === v.ref) + 1]?.ref);
}
}
if (violations.some((v) => v.code === 'window') || targets.length === 0) {
[...items].reverse().forEach((it) => add(it.ref));
}
return targets;
}
function causeOf(
ref: string,
violations: Violation[],
fallback: ViolationCode = 'window',
): ViolationCode {
return (
violations.find((v) => v.ref === ref && v.code !== 'window')?.code ??
fallback
);
}
interface RepairState {
engine: PlanEngine;
alternates: Map<string, PlanItem>;
keepMeals: boolean;
repairs: RepairLog;
notices: PlanNotice[];
}
/** 시도 중 위반 없이 짤 수 있는 가장 나은 것. 없으면 null */
function bestOf(
state: RepairState,
trials: { items: PlanItem[]; ref: string }[],
): { plan: OrderChoice; ref: string } | null {
let best: { plan: OrderChoice; ref: string } | null = null;
for (const trial of trials) {
const found = searchOrders(state.engine, trial.items, state.keepMeals);
if (found && (!best || isBetterChoice(found, best.plan, state.keepMeals))) {
best = { plan: found, ref: trial.ref };
}
}
return best;
}
/**
* 그날 시간 범위에 아예 머물 수 없는 곳(쉬는 날 등)은 순서를 찾기 전에 바꾸기 후보로 바꾸거나 뺀다.
* 순서를 바꿔도 소용없는데, 그대로 두면 모든 순서를 헛되이 따진다. 2곳 미만이 되면 null.
*/
function dropUnreachable(
state: RepairState,
items: PlanItem[],
violations: Violation[],
): PlanItem[] | null {
let current = items;
for (const item of items) {
if (isReachable(state.engine, item)) continue;
const alt = state.alternates.get(item.ref);
state.alternates.delete(item.ref);
if (
alt &&
isReachable(state.engine, alt) &&
!current.some((it) => it.ref === alt.ref)
) {
current = current.map((it) => (it.ref === item.ref ? alt : it));
state.repairs.add('replaced');
continue;
}
if (current.length <= STOPS_MIN) return null;
current = current.filter((it) => it.ref !== item.ref);
state.notices.push({
code: 'stop_removed',
ref: item.ref,
cause: causeOf(item.ref, violations, 'closed'),
});
state.repairs.add('removed');
}
return current;
}
/**
* 고치기 1~3(§3.6): 순서와 머무는 시간을 다시 찾고, 안 되면 위반한 장소를 바꾸기 후보로 바꾸고,
* 그래도 안 되면 뺀다(최소 2곳까지). 고칠 수 없으면 null.
*/
function repair(
state: RepairState,
items: PlanItem[],
violations: Violation[],
): SchedulePlan | null {
const reachable = dropUnreachable(state, items, violations);
if (!reachable) return null;
let current = reachable;
let flagged = violations;
for (;;) {
const found = searchOrders(state.engine, current, state.keepMeals);
if (found) return found;
if (state.engine.exhausted) return null;
const targets = repairTargets(current, flagged);
const replacements = targets.flatMap((ref) => {
const alt = state.alternates.get(ref);
if (!alt || current.some((it) => it.ref === alt.ref)) return [];
return [{ ref, items: current.map((it) => (it.ref === ref ? alt : it)) }];
});
const replaced = bestOf(state, replacements);
if (replaced) {
state.alternates.delete(replaced.ref);
state.repairs.add('replaced');
return replaced.plan;
}
if (current.length <= STOPS_MIN) return null;
const removals = targets.map((ref) => ({
ref,
items: current.filter((it) => it.ref !== ref),
}));
const removed = bestOf(state, removals);
const drop = removed?.ref ?? targets[0];
state.notices.push({
code: 'stop_removed',
ref: drop,
cause: causeOf(drop, flagged),
});
state.repairs.add('removed');
state.alternates.delete(drop);
if (removed) return removed.plan;
// 하나를 바꾸거나 빼서는 안 되면 첫 대상을 빼고 다시 찾는다
current = current.filter((it) => it.ref !== drop);
if (current.length < STOPS_MIN) return null;
flagged = timelineViolations(
state.engine,
current,
chain(
state.engine,
current,
current.map((it) => it.desired),
),
).filter(isHardViolation);
}
}
function indoorRatio(items: PlanItem[]): number {
return items.filter((it) => isIndoor(it.candidate)).length / items.length;
}
/**
* 비 조건이면 실내 비율 50% 이상을 맞춘다(§3.3·§3.6). 실외 장소를 실내 바꾸기 후보로 바꿔 보고,
* 실내 후보가 모자라 채울 수 없으면 코스는 그대로 내고 알린다.
*/
function fillIndoor(state: RepairState, plan: SchedulePlan): SchedulePlan {
let current = plan;
for (const ref of plan.items.map((it) => it.ref)) {
if (indoorRatio(current.items) >= RAIN_INDOOR_RATIO_MIN) break;
const item = current.items.find((it) => it.ref === ref);
const alt = state.alternates.get(ref);
if (!item || isIndoor(item.candidate) || !alt || !isIndoor(alt.candidate)) {
continue;
}
if (current.items.some((it) => it.ref === alt.ref)) continue;
const found = searchOrders(
state.engine,
current.items.map((it) => (it.ref === ref ? alt : it)),
mealsOk(state.engine.ctx, current.items, current.timeline),
);
if (!found) continue;
current = found;
state.alternates.delete(ref);
state.repairs.add('indoor_swap');
}
if (indoorRatio(current.items) < RAIN_INDOOR_RATIO_MIN) {
state.notices.push({ code: 'indoor_ratio_unmet' });
}
return current;
}
/**
* 바꾸기 후보를 바꿀 장소의 시간대에 넣어 본다 — 앞 장소에서 오는 이동과 다음 장소로 가는 이동을 다시 계산하고,
* 다음 장소 도착 시각은 그대로 둔다. 머무는 시간은 그 시간대와 분류 범위 안에서 영업 중인 가장 긴 시간이다.
* 마지막 장소면 원래 머무는 시간까지(끝 시각 + 10분 안). 맞지 않으면 null.
*/
function fitAlternate(
engine: PlanEngine,
plan: SchedulePlan,
index: number,
alt: PlanItem,
): PlannedAlternate | null {
const { items, timeline } = plan;
const prev = items[index - 1];
const next = items[index + 1];
const legFromPrev = prev ? engine.leg(prev, alt) : null;
const legToNext = next ? engine.leg(alt, next) : null;
for (const leg of [legFromPrev, legToNext]) {
if (leg && !isLegWithinLimit(leg, engine.ctx.mobility)) return null;
}
const arrive =
prev && legFromPrev
? timeline.leave[index - 1] + legFromPrev.minutes
: timeline.arrive[index];
if (!arrivesBeforeLastOrder(alt.candidate, arrive)) return null;
const { min, max } = stayRange(alt.candidate);
const original = timeline.leave[index] - timeline.arrive[index];
const cap =
next && legToNext
? Math.min(max, timeline.arrive[index + 1] - legToNext.minutes - arrive)
: Math.min(max, Math.max(min, original), engine.end - arrive);
const grid = engine.grid(alt);
for (let minutes = cap; minutes >= min; minutes -= TIME_STEP_MINUTES) {
const leave = arrive + minutes;
if (!canStay(grid, arrive, leave)) continue;
return {
forRef: items[index].ref,
ref: alt.ref,
candidate: alt.candidate,
arrive,
leave,
reason: alt.reason,
warnings: stopWarnings(engine, alt, arrive, leave),
legFromPrev,
legToNext,
};
}
return null;
}
function toPlannedStops(engine: PlanEngine, plan: SchedulePlan): PlannedStop[] {
return plan.items.map((item, i) => ({
ref: item.ref,
candidate: item.candidate,
arrive: plan.timeline.arrive[i],
leave: plan.timeline.leave[i],
reason: item.reason,
warnings: stopWarnings(
engine,
item,
plan.timeline.arrive[i],
plan.timeline.leave[i],
),
legToNext: plan.timeline.legs[i] ?? null,
}));
}
function totalsOf(stops: PlannedStop[]): PlanTotals {
return {
stops: stops.length,
totalMinutes: stops[stops.length - 1].leave - stops[0].arrive,
travelMinutes: stops.reduce(
(sum, s) => sum + (s.legToNext?.minutes ?? 0),
0,
),
petConfirmed: stops.filter((s) => isPetConfirmed(s.candidate)).length,
};
}
/** 고친 결과가 AI 의 순서·머무는 시간과 다른지 기록한다 */
function noteChanges(
plan: SchedulePlan,
ctx: PlanContext,
repairs: RepairLog,
): void {
const { items, timeline } = plan;
if (items.some((it, i) => i > 0 && items[i - 1].aiIndex > it.aiIndex)) {
repairs.add('reordered');
}
const changed =
timeline.arrive[0] !== ctx.start ||
items.some(
(it, i) => timeline.leave[i] - timeline.arrive[i] !== it.desired,
);
if (changed) repairs.add('durations');
}
/**
* AI 응답(고른 번호·머무는 시간·바꾸기 후보)으로 코스 초안을 만든다.
* 1) 목록 밖·중복 번호를 버린다 2) AI 순서대로 시간표를 짜고 검증한다 3) 위반이 있으면 고친다
* 4) 비 조건이면 실내 비율을 맞춘다 5) 바꾸기 후보를 검증한다.
* 장소를 뺐거나 AI 가 알림을 남겼으면 partial, 2곳 미만이면 failed(AI_003) 다.
*/
export function planCourse(
draft: CourseDraft,
shortlist: ShortlistEntry[],
ctx: PlanContext,
): PlanResult {
const repairs = new RepairLog();
if (draft.stops.length === 0) {
return {
outcome: 'failed',
cause: 'no_course',
violations: [],
repairs: [],
};
}
const byRef = new Map(shortlist.map((e) => [e.ref, e.candidate]));
const { items, violations: refViolations } = toPlanItems(draft.stops, byRef);
if (refViolations.length > 0) repairs.add('ref_dropped');
if (items.length < STOPS_MIN) {
return {
outcome: 'failed',
cause: 'unrepairable',
violations: [...refViolations, { code: 'too_few_stops' }],
repairs: repairs.list(),
};
}
const engine = new PlanEngine(ctx);
const initial = chain(
engine,
items,
items.map((it) => it.desired),
);
const initialViolations = timelineViolations(engine, items, initial);
const hard = initialViolations.filter(isHardViolation);
const state: RepairState = {
engine,
alternates: alternatesByStop(draft, byRef, items),
keepMeals: !initialViolations.some((v) => v.code === 'meal_time'),
repairs,
notices: [],
};
let plan: SchedulePlan | null = { items, timeline: initial };
if (hard.length > 0) {
plan = repair(state, items, hard);
if (!plan) {
return {
outcome: 'failed',
cause: 'unrepairable',
violations: [...refViolations, ...hard],
repairs: repairs.list(),
};
}
noteChanges(plan, ctx, repairs);
}
if (ctx.preferIndoor) plan = fillIndoor(state, plan);
const stops = toPlannedStops(engine, plan);
const used = new Set(stops.map((s) => s.ref));
const alternates: PlannedAlternate[] = [];
for (const [forRef, alt] of state.alternates) {
const index = plan.items.findIndex((it) => it.ref === forRef);
if (index < 0 || used.has(alt.ref)) continue;
const fitted = fitAlternate(engine, plan, index, alt);
if (fitted) alternates.push(fitted);
}
const softViolations: Violation[] = [];
if (!mealsOk(ctx, plan.items, plan.timeline)) {
softViolations.push({ code: 'meal_time' });
}
const kinds = new Set(plan.items.map((it) => diversityKey(it.candidate)));
const available = new Set(shortlist.map((e) => diversityKey(e.candidate)));
if (kinds.size < 2 && available.size >= 2) {
softViolations.push({ code: 'diversity' });
}
const reduced = state.notices.some((n) => n.code === 'stop_removed');
return {
outcome: reduced || draft.notices.length > 0 ? 'partial' : 'course',
stops,
alternates,
notices: state.notices,
repairs: repairs.list(),
softViolations,
totals: totalsOf(stops),
};
}
@@ -0,0 +1,130 @@
/**
* AI 코스 시간표 짜기의 기준값 — 설계 docs/design/ai-course.md §3.2–§3.6.
* 이동 계수·속도와 머무는 시간은 큐레이션 동네에서 실제로 걸어 본 시간과 비교해 맞춘다.
*/
import { PLACE_RADIUS_DEFAULT_M } from '@/modules/place/dto/place-search-query.dto';
import type { EventKind, PlaceCategory } from '@/modules/place/place.constants';
export const MOBILITIES = ['walk', 'transit', 'car'] as const;
export type Mobility = (typeof MOBILITIES)[number];
/** 후보를 찾는 반경(m). 걸어서는 장소 목록 API 기본값과 같고, 자동차는 도심에서 팔공산까지다 */
export const SEARCH_RADIUS_M: Record<Mobility, number> = {
walk: PLACE_RADIUS_DEFAULT_M,
transit: 6_000,
car: 20_000,
};
/** 직선거리로 이동 시간을 추정하는 식 — 고정 시간 + 직선거리 × 우회 계수 ÷ 분당 거리 */
export interface TravelModel {
/** 거리와 상관없이 드는 시간(분) — 대중교통은 걷기·대기, 자동차는 주차 */
fixedMinutes: number;
/** 직선거리를 실제 길 거리로 늘리는 계수 */
detourFactor: number;
/** 분당 이동 거리(m) */
metersPerMinute: number;
}
export const TRAVEL_MODELS: Record<Mobility, TravelModel> = {
walk: { fixedMinutes: 0, detourFactor: 1.3, metersPerMinute: 70 },
transit: { fixedMinutes: 10, detourFactor: 1.4, metersPerMinute: 300 },
car: { fixedMinutes: 5, detourFactor: 1.4, metersPerMinute: 400 },
};
/** 구간 최대 이동 시간(분) */
export const MAX_LEG_MINUTES: Record<Mobility, number> = {
walk: 15,
transit: 30,
car: 30,
};
/** 시간표의 최소 단위(분). 이동 시간은 이 단위로 올리고 머무는 시간은 이 단위로 맞춘다 */
export const TIME_STEP_MINUTES = 5;
/** 머무는 시간을 고칠 때 따져 보는 간격(분). AI 가 정한 값은 따로 넣는다 */
export const DURATION_STEP_MINUTES = 10;
/** 거르기에서 영업 여부를 확인하는 간격(분) */
export const OPEN_CHECK_STEP_MINUTES = 10;
/** 후보가 될 수 있는 분류 — 숙박은 순위와 관계없이 뺀다(§3.2). 행사는 'event' */
export type CandidateCategory = Exclude<PlaceCategory, 'lodging'> | 'event';
export interface StayRange {
min: number;
max: number;
}
/**
* 분류별 머무는 시간(분) — AI 가 정한 값을 이 범위로 맞춘다.
* 쇼핑·술집·기타는 설계 표에 없어 정했다(1순위 큐레이션 장소만 후보가 된다).
*/
export const STAY_MINUTES: Record<CandidateCategory, StayRange> = {
cafe: { min: 40, max: 90 },
food: { min: 60, max: 100 },
culture: { min: 50, max: 120 },
attraction: { min: 30, max: 120 },
nature: { min: 30, max: 120 },
leisure: { min: 60, max: 150 },
event: { min: 60, max: 150 },
shopping: { min: 30, max: 120 },
bar: { min: 60, max: 120 },
other: { min: 30, max: 120 },
};
/** 마지막 장소가 끝나도 되는 끝 시각 뒤 여유(분) */
export const WINDOW_GRACE_MINUTES = 10;
/** 하루의 끝(분). 코스는 자정을 넘기지 않는다(MVP) */
export const DAY_END_MINUTES = 1440;
/** 첫 장소가 문을 늦게 열면 시작 시각보다 이만큼(분)까지 늦게 도착해도 된다 */
export const FIRST_ARRIVAL_DELAY_MAX_MINUTES = 60;
/** 식당은 마지막 주문 이만큼(분) 전까지 도착한다 */
export const LAST_ORDER_ARRIVAL_MARGIN_MINUTES = 30;
export const STOPS_MIN = 2;
export const STOPS_MAX = 6;
/** 식사 시간대(분). 시간 범위가 걸치면 식당을 그 시간에 둔다 */
export const MEAL_WINDOWS = {
lunch: { start: 11 * 60 + 30, end: 14 * 60 },
dinner: { start: 17 * 60 + 30, end: 20 * 60 + 30 },
} as const;
export type Meal = keyof typeof MEAL_WINDOWS;
/** 강수확률이 이 값(%) 이상이고 indoorIfRain 이면 실내 위주로 짠다 */
export const RAIN_PROBABILITY_MIN = 40;
/** 비 조건일 때 실내 장소 비율 하한 */
export const RAIN_INDOOR_RATIO_MIN = 0.5;
/** 큐레이션 실내·외 값이 없을 때 실내로 보는 분류. 행사 중에는 전시가 실내다 */
export const INDOOR_CATEGORIES: readonly CandidateCategory[] = [
'culture',
'food',
'cafe',
'shopping',
'bar',
];
/** 큐레이션 값이 없을 때 실외로 보는 분류. 관광지·레저·축제·행사는 미확인이다 */
export const OUTDOOR_CATEGORIES: readonly CandidateCategory[] = ['nature'];
/** 영업시간이 미확인이어도 남기는 분류(야외라 시간 영향이 작다). 행사는 늘 남긴다 */
export const HOURS_UNKNOWN_ALLOWED: readonly CandidateCategory[] = [
'nature',
'attraction',
];
/** AI 후보에서 빼는 행사 종류 — 공연은 회차 시각을 아직 해석하지 않는다(§13) */
export const EXCLUDED_EVENT_KINDS: readonly EventKind[] = ['performance'];
/** 후보 줄이기 — 장소와 행사를 합쳐 이 수 이하(§3.4) */
export const SHORTLIST_MAX = 40;
/** 한 분류가 목록을 다 채우지 않게 하는 분류별 상한 */
export const SHORTLIST_PER_CATEGORY = 10;
/** 행사 상한(가까운 순) */
export const SHORTLIST_EVENTS_MAX = 8;
/** 다시 만들기 — 이전 결과의 장소를 빼도 이만큼 남으면 빼고, 아니면 목록 뒤로 보낸다 */
export const REGENERATE_EXCLUDE_MIN_REMAINING = 15;
/**
* 순서·시간 찾기에서 따져 볼 최대 순서 수(코스 하나 기준) — 서버의 이벤트 루프를 오래 막지 않게 한다.
* 2026-11-06 측정: 6곳 코스 고치기는 대부분 20ms 안이고, AI 순서가 거꾸로이면서 시간이 빠듯한 경우가 약 50ms(순서 약 700개)였다.
* 상한에 닿으면 고칠 수 없는 것으로 보고 위반 목록을 돌려준다(생성 작업이 AI 를 다시 부르거나 AI_003).
*/
export const MAX_ORDER_EVALUATIONS = 5_000;
@@ -0,0 +1,106 @@
/**
* 시간표 짜기 단위 테스트용 후보·조건 만들기. 운영 빌드에서는 빠진다(tsconfig.build.json 의 *.fixtures.ts).
*/
import type { LatLng } from '@/shared/utils/geo';
import { parseOpeningHours } from '@/shared/utils/opening-hours';
import type { Candidate, PlanContext } from './planner.types';
/** 기준 중심 — 대구 도심(앱의 downtown 과 같은 좌표) */
export const CENTER: LatLng = { lat: 35.87021, lng: 128.59409 };
/** 위도 1도의 거리(m, 지구 반지름 6,371km) */
const METERS_PER_LAT_DEGREE = 111_195;
/** 2026-11-07 토요일 · 2026-11-09 월요일 */
export const SAT = '2026-11-07';
export const MON = '2026-11-09';
/** 시·분 → 00:00 부터의 분 */
export const at = (hour: number, minute = 0): number => hour * 60 + minute;
/** 중심에서 동쪽 east m, 북쪽 north m 떨어진 좌표 */
export function offset(east: number, north: number, from = CENTER): LatLng {
const metersPerLngDegree =
METERS_PER_LAT_DEGREE * Math.cos((from.lat * Math.PI) / 180);
return {
lat: from.lat + north / METERS_PER_LAT_DEGREE,
lng: from.lng + east / metersPerLngDegree,
};
}
interface CandidateOptions extends Partial<Candidate> {
/** 영업시간 원문(null 이면 영업시간 없음) */
hoursText?: string | null;
restText?: string | null;
/** 중심에서 동쪽·북쪽 거리(m) */
east?: number;
north?: number;
}
let sequence = 0;
/** 장소 후보 — 기본값은 관광공사 문화시설(09:00~22:00, 반려 정보 없음), 중심 */
export function place(options: CandidateOptions = {}): Candidate {
const {
hoursText = '09:00~22:00',
restText = null,
east = 0,
north = 0,
...rest
} = options;
sequence += 1;
return {
kind: 'place',
id: `place-${sequence}`,
name: `장소 ${sequence}`,
category: 'culture',
eventKind: null,
...offset(east, north),
tier: 2,
setting: null,
hours: hoursText === null ? null : parseOpeningHours(hoursText, restText),
petPolicy: 'unknown',
petSizes: null,
startDate: null,
endDate: null,
...rest,
};
}
/** 행사 후보 — 기본값은 11월 한 달 하는 행사(운영 시간 10:00~21:00) */
export function event(options: CandidateOptions = {}): Candidate {
const { hoursText = '10:00~21:00', east = 0, north = 0, ...rest } = options;
sequence += 1;
return {
kind: 'event',
id: `event-${sequence}`,
name: `행사 ${sequence}`,
category: 'event',
eventKind: 'event',
...offset(east, north),
tier: null,
setting: null,
hours: hoursText === null ? null : parseOpeningHours(hoursText),
petPolicy: 'unknown',
petSizes: null,
startDate: '2026-11-01',
endDate: '2026-11-30',
...rest,
};
}
/** 조건 — 기본값은 토요일 14:00~20:00, 도심에서 걸어서, 반려동물·비 없음 */
export function context(overrides: Partial<PlanContext> = {}): PlanContext {
return {
date: SAT,
start: at(14),
end: at(20),
mobility: 'walk',
center: CENTER,
pet: null,
holiday: null,
preferIndoor: false,
previousIds: [],
...overrides,
};
}
@@ -0,0 +1,205 @@
/**
* AI 코스 시간표 짜기(순수 로직)의 입력·출력 형식 — 설계 docs/design/ai-course.md §3.3–§3.7.
* DB 행·AI 응답은 생성 작업(4단계)이 이 형식으로 바꿔 넘긴다.
*/
import type {
EventKind,
PetPolicy,
PetSize,
PlaceCategory,
PlaceSetting,
} from '@/modules/place/place.constants';
import type { LatLng } from '@/shared/utils/geo';
import type { HolidayKind } from '@/shared/utils/kr-holidays';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import type { Mobility } from './planner.constants';
/** AI 후보 하나 — place_listing 의 장소 또는 festival_event 의 행사 */
export interface Candidate {
kind: 'place' | 'event';
id: string;
name: string;
/** 장소 분류. 행사는 'event' */
category: PlaceCategory | 'event';
/** 행사 종류(장소는 null) */
eventKind: EventKind | null;
lat: number;
lng: number;
/** AI 후보 순위 — 1 확인한 곳 · 2 관광공사 장소. 행사와 후보가 아닌 장소는 null */
tier: 1 | 2 | null;
/** 큐레이션의 실내·외. 없으면 분류로 추정한다 */
setting: PlaceSetting | null;
/** 영업시간. 행사는 운영 시간 문구(playtime)를 같은 파서로 읽은 값. 없으면 null */
hours: ParsedOpeningHours | null;
petPolicy: PetPolicy;
/** 큐레이션에서 확인한 동반 가능 크기(없으면 null) */
petSizes: PetSize[] | null;
/** 행사 기간 'YYYY-MM-DD'(장소는 null) */
startDate: string | null;
endDate: string | null;
}
export interface PetCondition {
kind: 'dog' | 'cat' | 'other';
size: PetSize | null;
count: number;
}
/** 코스를 짤 조건 — 사용자가 확인한 조건(CourseConditions)과 서버가 아는 값 */
export interface PlanContext {
/** 한국 날짜 'YYYY-MM-DD' */
date: string;
/** 시작·끝 시각(00:00 부터 분). 자정을 넘기지 않는다 */
start: number;
end: number;
mobility: Mobility;
/** 지역 중심(동네 중심 · 현재 위치 · 기준 장소) */
center: LatLng;
pet: PetCondition | null;
/** 그날의 공휴일 종류(shared/utils/kr-holidays 의 holidayOn). 공휴일이 아니면 null */
holiday: HolidayKind | null;
/** 비 조건 — 강수확률 40% 이상이고 indoorIfRain(prefersIndoor) */
preferIndoor: boolean;
/** 다시 만들기 — 이전 결과의 장소·행사 id */
previousIds: string[];
}
/** AI 에 넘기는 후보 — 짧은 번호(P01·E01)로만 고르게 한다 */
export interface ShortlistEntry {
ref: string;
candidate: Candidate;
/** 지역 중심에서의 직선거리(m) */
distanceM: number;
}
/** AI 가 고른 장소 하나 */
export interface DraftStop {
ref: string;
/** 머무는 시간(분) */
minutes: number;
reason: string;
}
/** AI 가 낸 바꾸기 후보 */
export interface DraftAlternate {
/** 몇 번째 장소 대신인가(1부터) */
forStop: number;
ref: string;
reason: string;
}
/** AI 응답 중 시간표에 쓰는 부분(제목·요약은 생성 작업이 그대로 쓴다) */
export interface CourseDraft {
stops: DraftStop[];
alternates: DraftAlternate[];
/** AI 가 남긴 알림 — 조건을 다 맞추지 못한 이유, 들어줄 수 없는 부분 */
notices: string[];
}
/** 장소 사이 이동(추정) */
export interface Leg {
minutes: number;
meters: number;
/** 실제 이동 수단 — 대중교통·자동차라도 걷는 편이 빠르면 걷기 */
mode: Mobility;
}
export type StopWarning =
| 'hours_unknown'
| 'event_time_unknown'
| 'pet_unknown'
| 'event_last_day';
export interface PlannedStop {
ref: string;
candidate: Candidate;
/** 도착·떠나는 시각(00:00 부터 분) */
arrive: number;
leave: number;
reason: string;
warnings: StopWarning[];
legToNext: Leg | null;
}
/** 검증한 바꾸기 후보 — 앱이 AI 호출 없이 바꾼다 */
export interface PlannedAlternate {
/** 바꿀 장소의 번호 */
forRef: string;
ref: string;
candidate: Candidate;
arrive: number;
leave: number;
reason: string;
warnings: StopWarning[];
legFromPrev: Leg | null;
legToNext: Leg | null;
}
export type ViolationCode =
| 'unknown_ref'
| 'duplicate_ref'
| 'too_many_stops'
| 'too_few_stops'
| 'closed'
| 'last_order'
| 'window'
| 'leg_too_long'
| 'pet'
| 'meal_time'
| 'indoor_ratio'
| 'diversity';
export interface Violation {
code: ViolationCode;
ref?: string;
}
/** 코드가 남기는 알림 — 문구는 앱이 언어별로 만든다 */
export type PlanNoticeCode = 'stop_removed' | 'indoor_ratio_unmet';
export interface PlanNotice {
code: PlanNoticeCode;
/** 뺀 장소의 번호 */
ref?: string;
/** 뺀 이유 */
cause?: ViolationCode;
}
/** 거친 고치기 단계 — 품질 지표(ai_course_generation.repairs) */
export type RepairStep =
| 'ref_dropped'
| 'reordered'
| 'durations'
| 'replaced'
| 'removed'
| 'indoor_swap';
export interface PlanTotals {
stops: number;
/** 첫 도착부터 마지막 떠남까지(분) */
totalMinutes: number;
travelMinutes: number;
/** 반려동물 동반이 확인된(가능·일부 가능) 장소 수 */
petConfirmed: number;
}
export type PlanResult =
| {
outcome: 'course' | 'partial';
stops: PlannedStop[];
alternates: PlannedAlternate[];
notices: PlanNotice[];
repairs: RepairStep[];
/** 고치지 않고 둔 약한 위반(다양성·식사 시간대) */
softViolations: Violation[];
totals: PlanTotals;
}
| {
outcome: 'failed';
/** no_course: AI 가 요청의 핵심을 맞출 후보가 없다고 했다 · unrepairable: 고쳐도 2곳 미만 */
cause: 'no_course' | 'unrepairable';
/** AI 를 다시 부를 때 붙일 위반 목록 */
violations: Violation[];
repairs: RepairStep[];
};
@@ -0,0 +1,218 @@
import { at, context, place } from './planner.fixtures';
import type { Candidate, PlanContext } from './planner.types';
import {
chain,
fitMinutes,
fitTimes,
mealsOk,
overlappingMeals,
type PlanItem,
PlanEngine,
searchOrders,
timelineViolations,
} from './schedule';
let order = 0;
function item(ref: string, candidate: Candidate, desired: number): PlanItem {
return { ref, candidate, desired, reason: '', aiIndex: order++ };
}
function engineFor(overrides: Partial<PlanContext> = {}): PlanEngine {
order = 0;
return new PlanEngine(context(overrides));
}
const cafe = (east = 0, north = 0, hoursText = '10:00~22:00') =>
place({ category: 'cafe', east, north, hoursText });
describe('fitMinutes', () => {
it('분류 범위와 5분 단위로 맞춘다', () => {
const c = cafe();
expect(fitMinutes(c, 47)).toBe(45);
expect(fitMinutes(c, 20)).toBe(40);
expect(fitMinutes(c, 200)).toBe(90);
expect(fitMinutes(c, Number.NaN)).toBe(40);
});
});
describe('chain', () => {
it('정한 머무는 시간과 이동 시간을 시작 시각부터 이어 붙인다', () => {
const engine = engineFor();
const items = [item('A', place(), 60), item('B', cafe(700), 60)];
const timeline = chain(engine, items, [60, 60]);
expect(timeline.arrive).toEqual([at(14), at(15, 15)]);
expect(timeline.leave).toEqual([at(15), at(16, 15)]);
expect(timeline.legs).toEqual([{ minutes: 15, meters: 700, mode: 'walk' }]);
});
});
describe('timelineViolations', () => {
it('떠나기 전에 문을 닫으면 closed', () => {
const engine = engineFor();
const items = [
item('C', cafe(), 90),
item('M', place({ hoursText: '09:00~17:00' }), 120),
];
// 카페 14:00~15:30 → 박물관 15:30~17:30(17:00 에 닫는다)
const v = timelineViolations(
engine,
items,
chain(engine, items, [90, 120]),
);
expect(v).toEqual([{ code: 'closed', ref: 'M' }]);
});
it('끝 시각 + 10분을 넘기면 넘긴 첫 장소에 window 를 한 번만 붙인다', () => {
const engine = engineFor({ end: at(17) });
const items = [
item('A', place(), 120),
item('B', place(), 120),
item('C', place(), 60),
];
const v = timelineViolations(
engine,
items,
chain(engine, items, [120, 120, 60]),
);
expect(v).toEqual([{ code: 'window', ref: 'B' }]);
});
it('구간이 최대 이동 시간을 넘으면 leg_too_long', () => {
const engine = engineFor();
const items = [item('A', place(), 60), item('B', cafe(900), 60)];
const v = timelineViolations(engine, items, chain(engine, items, [60, 60]));
expect(v).toEqual([{ code: 'leg_too_long', ref: 'A' }]);
});
it('식당은 마지막 주문 30분 전까지 도착해야 한다', () => {
const engine = engineFor({ start: at(19), end: at(22, 30) });
const restaurant = place({
category: 'food',
hoursText: '10:00~23:00 (마지막 주문 21:00)',
});
const items = [item('M', place(), 120), item('F', restaurant, 60)];
// 박물관 19:00~21:00 → 식당 21:00 도착(20:30 까지 와야 한다)
const v = timelineViolations(
engine,
items,
chain(engine, items, [120, 60]),
);
expect(v).toContainEqual({ code: 'last_order', ref: 'F' });
});
it('반려동물 조건을 어긴 장소는 pet', () => {
const engine = engineFor({ pet: { kind: 'dog', size: null, count: 1 } });
const items = [item('C', cafe(), 60), item('M', place(), 60)];
const v = timelineViolations(engine, items, chain(engine, items, [60, 60]));
expect(v).toEqual([
{ code: 'pet', ref: 'C' },
{ code: 'pet', ref: 'M' },
]);
});
});
describe('overlappingMeals · mealsOk', () => {
it('시간 범위에 걸치는 식사 시간대만 따진다', () => {
expect(overlappingMeals({ start: at(11), end: at(15) })).toEqual(['lunch']);
expect(overlappingMeals({ start: at(14), end: at(22) })).toEqual([
'dinner',
]);
expect(overlappingMeals({ start: at(14, 30), end: at(17) })).toEqual([]);
});
it('식당이 걸치는 식사 시간대 안에 도착해야 한다', () => {
const engine = engineFor({ start: at(11), end: at(15) });
const restaurant = place({ category: 'food', hoursText: '11:00~21:00' });
const early = [item('F', restaurant, 60), item('C', cafe(), 60)];
expect(mealsOk(engine.ctx, early, chain(engine, early, [60, 60]))).toBe(
false,
);
const late = [item('C', cafe(), 60), item('F', restaurant, 60)];
expect(mealsOk(engine.ctx, late, chain(engine, late, [60, 60]))).toBe(true);
});
});
describe('fitTimes', () => {
it('문을 늦게 여는 곳은 앞 장소에 더 머물러 도착을 늦춘다', () => {
const engine = engineFor({ start: at(15), end: at(21) });
const items = [
item('P', place({ category: 'nature', hoursText: null }), 60),
item('C', cafe(300), 60),
item(
'F',
place({ category: 'food', east: 600, hoursText: '17:30~22:00' }),
90,
),
];
// 그대로 이으면 식당에 17:20 에 도착한다(17:30 에 연다)
expect(chain(engine, items, [60, 60, 90]).arrive[2]).toBe(at(17, 20));
const fit = fitTimes(engine, items);
expect(fit?.timeline.arrive[2]).toBe(at(17, 30));
expect(fit?.deviation).toBe(10);
expect(timelineViolations(engine, items, fit!.timeline)).toEqual([]);
});
it('첫 장소가 늦게 열면 첫 도착을 늦춘다', () => {
const engine = engineFor({ start: at(10), end: at(14) });
const items = [
item('M', place({ hoursText: '10:30~18:00' }), 60),
item('C', cafe(), 60),
];
const fit = fitTimes(engine, items);
expect(fit?.timeline.arrive).toEqual([at(10, 30), at(11, 30)]);
expect(fit?.deviation).toBe(30);
});
it('끝 시각을 넘기면 범위 안에서 머무는 시간을 줄인다', () => {
const engine = engineFor({ end: at(17) });
const items = [item('M', place(), 120), item('C', cafe(), 90)];
const fit = fitTimes(engine, items);
expect(fit?.timeline.leave[1]).toBe(at(17, 10));
expect(fit?.deviation).toBe(20);
});
it('그날 열지 않는 곳이 있으면 찾지 못한다', () => {
const engine = engineFor();
const items = [
item('C', cafe(), 60),
item('M', place({ restText: '매주 토요일' }), 60),
];
expect(fitTimes(engine, items)).toBeNull();
});
});
describe('searchOrders', () => {
it('AI 순서로 짤 수 있으면 순서를 바꾸지 않는다', () => {
const engine = engineFor();
const items = [item('A', place(), 60), item('B', cafe(300), 60)];
const found = searchOrders(engine, items, true);
expect(found?.items.map((it) => it.ref)).toEqual(['A', 'B']);
expect(found?.inversions).toBe(0);
});
it('일찍 닫는 곳을 앞으로 옮기되 AI 순서와 가장 비슷한 순서를 고른다', () => {
const engine = engineFor({ end: at(18) });
const items = [
item('C', cafe(), 60),
item('P', place({ category: 'nature', east: 300, hoursText: null }), 60),
item('M', place({ north: 300, hoursText: '09:00~16:00' }), 60),
];
const found = searchOrders(engine, items, true);
// 박물관을 맨 앞(바뀐 쌍 2개)이 아니라 둘째(1개)에 둔다
expect(found?.items.map((it) => it.ref)).toEqual(['C', 'M', 'P']);
expect(found?.inversions).toBe(1);
expect(timelineViolations(engine, found!.items, found!.timeline)).toEqual(
[],
);
});
it('어떤 순서로도 짤 수 없으면 null', () => {
const engine = engineFor({ end: at(16) });
const items = [
item('A', place(), 120),
item('B', place(), 120),
item('C', place(), 120),
];
expect(searchOrders(engine, items, true)).toBeNull();
});
});
@@ -0,0 +1,493 @@
/**
* 시간표·검증·순서와 머무는 시간 고치기(설계 §3.6).
* 장소가 6곳 이하라 모든 순서를 따져 볼 수 있다. 시각은 00:00 부터의 분이고 5분 단위다.
*/
import {
type AvailabilityGrid,
buildGrid,
canStay,
hasUnknownHours,
scheduleEnd,
} from './availability';
import { candidateCategory, petExclusion, stayRange } from './candidate';
import {
DURATION_STEP_MINUTES,
FIRST_ARRIVAL_DELAY_MAX_MINUTES,
LAST_ORDER_ARRIVAL_MARGIN_MINUTES,
MAX_ORDER_EVALUATIONS,
type Meal,
MEAL_WINDOWS,
TIME_STEP_MINUTES,
} from './planner.constants';
import type {
Candidate,
Leg,
PlanContext,
StopWarning,
Violation,
ViolationCode,
} from './planner.types';
import { estimateLeg, isLegWithinLimit } from './travel';
/** 시간표에 올릴 장소 하나 */
export interface PlanItem {
ref: string;
candidate: Candidate;
/** AI 가 정한 머무는 시간을 분류 범위·5분 단위로 맞춘 값 */
desired: number;
reason: string;
/** AI 가 정한 순서(0부터). 바꾸기 후보로 바꾼 곳은 원래 장소의 순서를 이어받는다 */
aiIndex: number;
}
/** 순서대로 이어 붙인 시각과 구간 */
export interface Timeline {
arrive: number[];
leave: number[];
/** legs[i] = i 번째에서 다음 장소로 가는 이동 */
legs: Leg[];
}
export interface SchedulePlan {
items: PlanItem[];
timeline: Timeline;
}
/** 순서·시간 찾기 결과 */
export interface OrderChoice extends SchedulePlan {
/** AI 순서에서 앞뒤가 바뀐 쌍의 수 */
inversions: number;
/** 이동 시간 합계(분) */
travel: number;
/** AI 가 정한 머무는 시간에서 바뀐 정도(분) + 첫 도착을 늦춘 시간(분) */
deviation: number;
mealOk: boolean;
}
/** 위반을 고쳐야 하는 것(강한 위반). 식사 시간대·실내 비율·다양성은 약한 위반이다 */
const HARD_VIOLATIONS: readonly ViolationCode[] = [
'closed',
'last_order',
'window',
'leg_too_long',
'pet',
];
export function isHardViolation(v: Violation): boolean {
return HARD_VIOLATIONS.includes(v.code);
}
/** 도착할 수 있는 시각 범위(분) */
export interface ArrivalWindow {
earliest: number;
latest: number;
}
/**
* 코스 하나를 짜는 동안 같은 계산(장소별 영업 칸·구간 이동)을 다시 하지 않게 모아 둔다.
* 순서·시간 찾기 횟수도 센다 — 서버의 이벤트 루프를 오래 막지 않게 상한을 둔다.
*/
export class PlanEngine {
/** 코스가 끝나야 하는 시각(끝 시각 + 10분) */
readonly end: number;
private readonly grids = new Map<string, AvailabilityGrid>();
private readonly legs = new Map<string, Leg>();
private readonly windows = new Map<string, ArrivalWindow | null>();
private evaluations = 0;
constructor(readonly ctx: PlanContext) {
this.end = scheduleEnd(ctx);
}
grid(item: Pick<PlanItem, 'ref' | 'candidate'>): AvailabilityGrid {
let grid = this.grids.get(item.ref);
if (!grid) {
grid = buildGrid(item.candidate, this.ctx.date, this.ctx.start, this.end);
this.grids.set(item.ref, grid);
}
return grid;
}
/**
* 그 장소에 도착할 수 있는 가장 이른·늦은 시각 — 최소 머무는 시간만큼 머물 수 있고, 식당은 마지막 주문 30분 전까지.
* 시간 범위 어디에도 머물 수 없으면 null(그날 쉬는 곳 등).
*/
arrivalWindow(
item: Pick<PlanItem, 'ref' | 'candidate'>,
): ArrivalWindow | null {
if (this.windows.has(item.ref)) return this.windows.get(item.ref) ?? null;
const grid = this.grid(item);
const { min } = stayRange(item.candidate);
let earliest: number | null = null;
let latest = 0;
for (let t = this.ctx.start; t + min <= this.end; t += TIME_STEP_MINUTES) {
if (
canStay(grid, t, t + min) &&
arrivesBeforeLastOrder(item.candidate, t)
) {
earliest ??= t;
latest = t;
}
}
const window = earliest === null ? null : { earliest, latest };
this.windows.set(item.ref, window);
return window;
}
leg(
from: Pick<PlanItem, 'ref' | 'candidate'>,
to: Pick<PlanItem, 'ref' | 'candidate'>,
): Leg {
const key = `${from.ref}>${to.ref}`;
let leg = this.legs.get(key);
if (!leg) {
leg = estimateLeg(from.candidate, to.candidate, this.ctx.mobility);
this.legs.set(key, leg);
}
return leg;
}
/** 순서 하나를 따져 볼 수 있으면 센다. 상한에 닿았으면 false */
takeEvaluation(): boolean {
if (this.evaluations >= MAX_ORDER_EVALUATIONS) return false;
this.evaluations += 1;
return true;
}
get exhausted(): boolean {
return this.evaluations >= MAX_ORDER_EVALUATIONS;
}
}
/** AI 가 정한 머무는 시간을 분류 범위와 5분 단위로 맞춘다 */
export function fitMinutes(c: Candidate, minutes: number): number {
const { min, max } = stayRange(c);
if (!Number.isFinite(minutes)) return min;
const rounded = Math.round(minutes / TIME_STEP_MINUTES) * TIME_STEP_MINUTES;
return Math.min(max, Math.max(min, rounded));
}
/** 식당이 마지막 주문 30분 전까지 도착하는가(마지막 주문을 모르면 따지지 않는다) */
export function arrivesBeforeLastOrder(c: Candidate, arrive: number): boolean {
const lastOrder = c.hours?.lastOrder ?? null;
if (candidateCategory(c) !== 'food' || lastOrder === null) return true;
return arrive <= lastOrder - LAST_ORDER_ARRIVAL_MARGIN_MINUTES;
}
/** 시간 범위에 걸치는 식사 시간대 */
export function overlappingMeals(
ctx: Pick<PlanContext, 'start' | 'end'>,
): Meal[] {
return (Object.keys(MEAL_WINDOWS) as Meal[]).filter(
(meal) =>
MEAL_WINDOWS[meal].start < ctx.end && ctx.start < MEAL_WINDOWS[meal].end,
);
}
/** 식당마다 시간 범위에 걸치는 식사 시간대 안에 도착하는가(§3.5). 걸치는 시간대가 없으면 따지지 않는다 */
export function mealsOk(
ctx: Pick<PlanContext, 'start' | 'end'>,
items: PlanItem[],
timeline: Timeline,
): boolean {
const meals = overlappingMeals(ctx);
if (meals.length === 0) return true;
return items.every(
(item, i) =>
candidateCategory(item.candidate) !== 'food' ||
meals.some(
(meal) =>
timeline.arrive[i] >= MEAL_WINDOWS[meal].start &&
timeline.arrive[i] < MEAL_WINDOWS[meal].end,
),
);
}
/** 정한 순서·머무는 시간으로 첫 도착 시각부터 이어 붙인다 */
export function chain(
engine: PlanEngine,
items: PlanItem[],
minutes: number[],
firstArrive = engine.ctx.start,
): Timeline {
const timeline: Timeline = { arrive: [], leave: [], legs: [] };
let t = firstArrive;
items.forEach((item, i) => {
timeline.arrive.push(t);
t += minutes[i];
timeline.leave.push(t);
const next = items[i + 1];
if (next) {
const leg = engine.leg(item, next);
timeline.legs.push(leg);
t += leg.minutes;
}
});
return timeline;
}
/**
* 시간표 검증(§3.6) — 영업(브레이크 포함, 도착부터 떠날 때까지) · 식당 마지막 주문 · 끝 시각 + 10분 ·
* 구간 최대 이동 시간 · 반려동물 · 식사 시간대(약한 위반).
*/
export function timelineViolations(
engine: PlanEngine,
items: PlanItem[],
timeline: Timeline,
): Violation[] {
const { ctx } = engine;
const out: Violation[] = [];
let overrun = false;
items.forEach((item, i) => {
const c = item.candidate;
const arrive = timeline.arrive[i];
const leave = timeline.leave[i];
if (petExclusion(c, ctx.pet)) out.push({ code: 'pet', ref: item.ref });
if (leave > engine.end) {
// 끝 시각을 넘긴 첫 장소에만 붙인다(뒤 장소는 모두 넘긴다)
if (!overrun) out.push({ code: 'window', ref: item.ref });
overrun = true;
} else if (!canStay(engine.grid(item), arrive, leave)) {
out.push({ code: 'closed', ref: item.ref });
}
if (!arrivesBeforeLastOrder(c, arrive)) {
out.push({ code: 'last_order', ref: item.ref });
}
const leg = timeline.legs[i];
if (leg && !isLegWithinLimit(leg, ctx.mobility)) {
out.push({ code: 'leg_too_long', ref: item.ref });
}
});
if (!mealsOk(ctx, items, timeline)) out.push({ code: 'meal_time' });
return out;
}
/** 머무는 시간 후보 — 범위 안 10분 간격 + 최대 + AI 가 정한 값(오름차순) */
function durationOptions(item: PlanItem): number[] {
const { min, max } = stayRange(item.candidate);
const options = new Set<number>([item.desired, max]);
for (let d = min; d <= max; d += DURATION_STEP_MINUTES) options.add(d);
return [...options].sort((a, b) => a - b);
}
/** 시간 범위 어디에도 최소 머무는 시간만큼 머물 수 없는 곳인가(그날 쉬는 곳 등) — 순서를 바꿔도 소용없다 */
export function isReachable(
engine: PlanEngine,
item: Pick<PlanItem, 'ref' | 'candidate'>,
): boolean {
return engine.arrivalWindow(item) !== null;
}
/**
* 동적 계획법 전에 안 되는 순서를 싸게 거른다 — 장소마다 도착할 수 있는 시각 범위만 보고,
* 기다리는 시간은 제한 없이 둔 채 가장 짧게 머물며 이어 본다. 이것도 안 되면 머무는 시간을 어떻게 바꿔도 안 된다.
*/
function mayFit(engine: PlanEngine, items: PlanItem[], legs: Leg[]): boolean {
let t = engine.ctx.start;
for (let i = 0; i < items.length; i++) {
const window = engine.arrivalWindow(items[i]);
if (!window) return false;
t = Math.max(t, window.earliest);
if (t > window.latest) return false;
t += stayRange(items[i].candidate).min + (legs[i]?.minutes ?? 0);
}
return true;
}
/**
* 정한 순서에서 위반 없는 머무는 시간을 찾는다(§3.6 고치기 2, 5분 단위 동적 계획법).
* 장소마다 분류 범위 안에서 영업 중인 동안만 머물고, 식당은 마지막 주문 전에 도착하고, 끝 시각 + 10분 안에 끝나야 한다.
* 문을 늦게 여는 곳은 앞 장소에 더 머물러 도착을 늦추고, 첫 장소가 늦게 열면 첫 도착을 1시간까지 늦춘다.
* 그중 AI 가 정한 시간과 가장 가까운 것(같으면 일찍 끝나는 것)을 고른다. 없으면 null.
* 순서마다 불리므로(최대 720번) 시각을 시작 시각부터의 5분 칸 번호로 두고 배열로 계산한다.
*/
export function fitTimes(
engine: PlanEngine,
items: PlanItem[],
): { timeline: Timeline; deviation: number } | null {
const { ctx } = engine;
const step = TIME_STEP_MINUTES;
const n = items.length;
if (n === 0) return null;
const legs: Leg[] = [];
for (let i = 0; i + 1 < n; i++) {
const leg = engine.leg(items[i], items[i + 1]);
if (!isLegWithinLimit(leg, ctx.mobility)) return null;
legs.push(leg);
}
if (!mayFit(engine, items, legs)) return null;
// restMin[i] = i 번째에 도착한 뒤 남은 장소를 가장 짧게 돌 때 걸리는 시간(가지치기)
const restMin: number[] = new Array<number>(n + 1).fill(0);
for (let i = n - 1; i >= 0; i--) {
const legAfter = i < n - 1 ? legs[i].minutes : 0;
restMin[i] = stayRange(items[i].candidate).min + legAfter + restMin[i + 1];
}
if (ctx.start + restMin[0] > engine.end) return null;
// cost[k] = 이 단계 장소에 (시작 + 5분 × k) 에 도착하는 가장 작은 비용(머무는 시간 변경 + 첫 도착 늦춤, 분)
const slots = Math.floor((engine.end - ctx.start) / step);
let cost = new Float64Array(slots + 1).fill(Infinity);
// 도착할 수 있는 칸의 범위 — 그 밖은 볼 필요가 없다
let lo = 0;
let hi = 0;
for (let delay = 0; delay <= FIRST_ARRIVAL_DELAY_MAX_MINUTES; delay += step) {
if (ctx.start + delay + restMin[0] > engine.end) break;
cost[delay / step] = delay;
hi = delay / step;
}
// 되짚기용 — 단계 i 에서 다음 칸 k 로 온 앞 칸과 머문 시간
const fromSlot: Int16Array[] = [];
const stayed: Int16Array[] = [];
for (let i = 0; i < n; i++) {
const item = items[i];
const grid = engine.grid(item);
const options = durationOptions(item);
const legAfter = i < n - 1 ? legs[i].minutes : 0;
const after = i < n - 1 ? legAfter + restMin[i + 1] : 0;
const next = new Float64Array(slots + 1).fill(Infinity);
const prev = new Int16Array(slots + 1).fill(-1);
const minutesAt = new Int16Array(slots + 1);
let nextLo = slots;
let nextHi = 0;
let reached = false;
for (let k = lo; k <= hi; k++) {
const base = cost[k];
if (base === Infinity) continue;
const arrive = ctx.start + k * step;
if (!arrivesBeforeLastOrder(item.candidate, arrive)) continue;
for (const minutes of options) {
const leave = arrive + minutes;
// 더 오래 머물면 더 늦어지고, 닫힌 때도 그대로 지나므로 멈춘다
if (leave + after > engine.end) break;
if (!canStay(grid, arrive, leave)) break;
const target = (leave + legAfter - ctx.start) / step;
const total = base + Math.abs(minutes - item.desired);
if (total < next[target]) {
next[target] = total;
prev[target] = k;
minutesAt[target] = minutes;
reached = true;
nextLo = Math.min(nextLo, target);
nextHi = Math.max(nextHi, target);
}
}
}
if (!reached) return null;
fromSlot.push(prev);
stayed.push(minutesAt);
cost = next;
lo = nextLo;
hi = nextHi;
}
// 비용이 가장 작은 칸(같으면 일찍 끝나는 칸)
let best = -1;
for (let k = 0; k <= slots; k++) {
if (cost[k] < (best < 0 ? Infinity : cost[best])) best = k;
}
if (best < 0) return null;
const arrive: number[] = new Array<number>(n);
const leave: number[] = new Array<number>(n);
for (let i = n - 1, k = best; i >= 0; i--) {
const from = fromSlot[i][k];
arrive[i] = ctx.start + from * step;
leave[i] = arrive[i] + stayed[i][k];
k = from;
}
return { timeline: { arrive, leave, legs }, deviation: cost[best] };
}
/** n 곳의 모든 순서(자리 번호), 앞뒤가 바뀐 쌍이 적은 순 — 한 번 만들어 둔다 */
const ORDERS_BY_SWAPS = new Map<number, { order: number[]; swaps: number }[]>();
function ordersBySwaps(n: number): { order: number[]; swaps: number }[] {
const cached = ORDERS_BY_SWAPS.get(n);
if (cached) return cached;
const out: { order: number[]; swaps: number }[] = [];
const index = Array.from({ length: n }, (_, i) => i);
for (;;) {
let swaps = 0;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) if (index[i] > index[j]) swaps += 1;
}
out.push({ order: [...index], swaps });
// 다음 순열(사전 순): 뒤에서부터 처음으로 오름차순이 깨지는 곳을 찾아 바꾸고 뒤를 뒤집는다
let i = n - 2;
while (i >= 0 && index[i] >= index[i + 1]) i -= 1;
if (i < 0) break;
let j = n - 1;
while (index[j] <= index[i]) j -= 1;
[index[i], index[j]] = [index[j], index[i]];
index.splice(i + 1, n - i - 1, ...index.slice(i + 1).reverse());
}
out.sort((a, b) => a.swaps - b.swaps);
ORDERS_BY_SWAPS.set(n, out);
return out;
}
/** 순서·시간 찾기 결과 비교 — 식사 시간대(지켜야 할 때) → 순서 변경 → 이동 → 시간 변경 */
export function isBetterChoice(
a: OrderChoice,
b: OrderChoice,
keepMeals: boolean,
): boolean {
if (keepMeals && a.mealOk !== b.mealOk) return a.mealOk;
if (a.inversions !== b.inversions) return a.inversions < b.inversions;
if (a.travel !== b.travel) return a.travel < b.travel;
return a.deviation < b.deviation;
}
/**
* 순서 바꾸기·머무는 시간 조정(§3.6 고치기 1·2)을 함께 찾는다.
* 순서(6곳이면 720가지)마다 위반 없는 머무는 시간을 찾고, 그중
* ① AI 순서가 식사 시간대를 지켰으면(keepMeals) 계속 지키는 것 ② AI 순서와 가장 비슷한 것(앞뒤가 바뀐 쌍이 적은 것)
* ③ 이동이 짧은 것 ④ 머무는 시간이 덜 바뀐 것을 고른다. 요청에 담긴 순서("레스토랑으로 마무리")를 지키려고 AI 순서를 앞에 둔다.
* AI 순서와 가까운 순서부터 따지므로, 답을 찾으면 앞뒤가 더 많이 바뀐 순서는 보지 않는다.
*/
export function searchOrders(
engine: PlanEngine,
items: PlanItem[],
keepMeals: boolean,
): OrderChoice | null {
const byAi = [...items].sort((a, b) => a.aiIndex - b.aiIndex);
let best: OrderChoice | null = null;
for (const { order, swaps } of ordersBySwaps(byAi.length)) {
// 식사 시간대로 앞설 수 있을 때만 앞뒤가 더 많이 바뀐 순서를 본다
if (best && swaps > best.inversions && !(keepMeals && !best.mealOk)) break;
if (!engine.takeEvaluation()) break;
const ordered = order.map((i) => byAi[i]);
const fit = fitTimes(engine, ordered);
if (!fit) continue;
const choice: OrderChoice = {
items: ordered,
timeline: fit.timeline,
inversions: swaps,
travel: fit.timeline.legs.reduce((sum, leg) => sum + leg.minutes, 0),
deviation: fit.deviation,
mealOk: mealsOk(engine.ctx, ordered, fit.timeline),
};
if (!best || isBetterChoice(choice, best, keepMeals)) best = choice;
}
return best;
}
/** 장소 하나의 경고(§3.7) */
export function stopWarnings(
engine: PlanEngine,
item: Pick<PlanItem, 'ref' | 'candidate'>,
arrive: number,
leave: number,
): StopWarning[] {
const { ctx } = engine;
const c = item.candidate;
const warnings: StopWarning[] = [];
if (hasUnknownHours(engine.grid(item), arrive, leave)) {
warnings.push(c.kind === 'event' ? 'event_time_unknown' : 'hours_unknown');
}
if (ctx.pet && c.petPolicy === 'unknown') warnings.push('pet_unknown');
if (c.kind === 'event' && c.endDate === ctx.date) {
warnings.push('event_last_day');
}
return warnings;
}
@@ -0,0 +1,94 @@
import { CENTER, event, place } from './planner.fixtures';
import type { Candidate } from './planner.types';
import { shortlistCandidates } from './shortlist';
const ctx = { center: CENTER, previousIds: [] as string[] };
/** east m 간격으로 늘어선 같은 분류 장소 count 곳 */
function row(
count: number,
category: Candidate['category'],
start = 100,
): Candidate[] {
return Array.from({ length: count }, (_, i) =>
place({ category, east: start + i * 10 }),
);
}
describe('shortlistCandidates', () => {
it('1순위(확인한 곳)를 먼저, 그다음 가까운 순으로 두고 P01 부터 번호를 붙인다', () => {
const near = place({ category: 'cafe', east: 100 });
const far = place({ category: 'food', east: 900 });
const curated = place({ category: 'cafe', east: 1200, tier: 1 });
const list = shortlistCandidates([near, far, curated], ctx);
expect(list.map((e) => [e.ref, e.candidate.id])).toEqual([
['P01', curated.id],
['P02', near.id],
['P03', far.id],
]);
expect(Math.round(list[1].distanceM)).toBe(100);
});
it('한 분류는 10곳까지만 넣는다', () => {
const list = shortlistCandidates(
[...row(12, 'attraction'), ...row(3, 'cafe', 500)],
ctx,
);
const count = (category: string) =>
list.filter((e) => e.candidate.category === category).length;
expect(count('attraction')).toBe(10);
expect(count('cafe')).toBe(3);
});
it('행사는 가까운 8개만 E01 부터 넣고, 장소와 합쳐 40곳 이하다', () => {
const events = Array.from({ length: 10 }, (_, i) =>
event({ east: 100 + i * 50 }),
);
const places = [
...row(10, 'attraction'),
...row(10, 'culture'),
...row(10, 'cafe'),
...row(10, 'food'),
...row(10, 'nature'),
];
const list = shortlistCandidates([...events, ...places], ctx);
const refs = list.map((e) => e.ref);
expect(list).toHaveLength(40);
expect(refs.filter((r) => r.startsWith('E'))).toEqual([
'E01',
'E02',
'E03',
'E04',
'E05',
'E06',
'E07',
'E08',
]);
expect(refs.filter((r) => r.startsWith('P'))).toHaveLength(32);
// 가장 먼 행사 둘은 빠진다
expect(list.some((e) => e.candidate.id === events[9].id)).toBe(false);
});
it('다시 만들기 — 이전 장소를 빼도 15곳 이상 남으면 뺀다', () => {
const fresh = row(15, 'attraction');
const previous = place({ category: 'cafe', east: 10 });
const list = shortlistCandidates([previous, ...fresh], {
center: CENTER,
previousIds: [previous.id],
});
expect(list.some((e) => e.candidate.id === previous.id)).toBe(false);
});
it('다시 만들기 — 남는 후보가 15곳보다 적으면 이전 장소를 목록 뒤로 보낸다', () => {
const fresh = row(3, 'attraction', 500);
const previous = place({ category: 'attraction', east: 10, tier: 1 });
const list = shortlistCandidates([previous, ...fresh], {
center: CENTER,
previousIds: [previous.id],
});
expect(list.map((e) => e.candidate.id)).toEqual([
...fresh.map((c) => c.id),
previous.id,
]);
});
});
@@ -0,0 +1,74 @@
/**
* 후보 줄이기(설계 §3.4) — AI 에 너무 많이 주면 비용이 늘고 고르는 품질도 떨어진다.
* 장소와 행사를 합쳐 40곳 이하로 줄이고 짧은 번호(P01·E01)를 붙인다. 번호와 id 의 대응은 서버만 가진다.
*/
import { distanceMeters } from '@/shared/utils/geo';
import {
REGENERATE_EXCLUDE_MIN_REMAINING,
SHORTLIST_EVENTS_MAX,
SHORTLIST_MAX,
SHORTLIST_PER_CATEGORY,
} from './planner.constants';
import type { Candidate, PlanContext, ShortlistEntry } from './planner.types';
type Entry = Omit<ShortlistEntry, 'ref'>;
/** 순위가 없을 때의 정렬값(1·2순위 뒤) */
const NO_TIER = 3;
const byDistance = (a: Entry, b: Entry) => a.distanceM - b.distanceM;
const byTierThenDistance = (a: Entry, b: Entry) =>
(a.candidate.tier ?? NO_TIER) - (b.candidate.tier ?? NO_TIER) ||
a.distanceM - b.distanceM;
const refOf = (prefix: 'P' | 'E', index: number) =>
`${prefix}${String(index + 1).padStart(2, '0')}`;
/**
* - 장소: 1순위(확인한 곳)를 먼저, 그다음 가까운 순. 분류마다 최대 10곳
* - 행사: 가까운 순으로 최대 8개. 장소는 40곳에서 행사 수를 뺀 만큼
* - 다시 만들기: 이전 결과의 장소를 빼도 15곳 이상 남으면 빼고, 아니면 목록 뒤로 보낸다
*/
export function shortlistCandidates(
kept: Candidate[],
ctx: Pick<PlanContext, 'center' | 'previousIds'>,
): ShortlistEntry[] {
const previous = new Set(ctx.previousIds);
const entries: Entry[] = kept.map((candidate) => ({
candidate,
distanceM: distanceMeters(ctx.center, candidate),
}));
const fresh = entries.filter((e) => !previous.has(e.candidate.id));
const repeated =
fresh.length >= REGENERATE_EXCLUDE_MIN_REMAINING
? []
: entries.filter((e) => previous.has(e.candidate.id));
const isEvent = (e: Entry) => e.candidate.kind === 'event';
const isPlace = (e: Entry) => e.candidate.kind === 'place';
const events = [
...fresh.filter(isEvent).sort(byDistance),
...repeated.filter(isEvent).sort(byDistance),
].slice(0, SHORTLIST_EVENTS_MAX);
const placeLimit = SHORTLIST_MAX - events.length;
const perCategory = new Map<string, number>();
const places: Entry[] = [];
for (const e of [
...fresh.filter(isPlace).sort(byTierThenDistance),
...repeated.filter(isPlace).sort(byTierThenDistance),
]) {
if (places.length >= placeLimit) break;
const count = perCategory.get(e.candidate.category) ?? 0;
if (count >= SHORTLIST_PER_CATEGORY) continue;
perCategory.set(e.candidate.category, count + 1);
places.push(e);
}
return [
...places.map((e, i) => ({ ref: refOf('P', i), ...e })),
...events.map((e, i) => ({ ref: refOf('E', i), ...e })),
];
}
@@ -0,0 +1,52 @@
import { CENTER, offset } from './planner.fixtures';
import { ceilToStep, estimateLeg, isLegWithinLimit } from './travel';
describe('ceilToStep', () => {
it('5분 단위로 올리되, 부동소수점 오차로 한 칸 더 올리지 않는다', () => {
expect(ceilToStep(0)).toBe(0);
expect(ceilToStep(0.1)).toBe(5);
expect(ceilToStep(15)).toBe(15);
expect(ceilToStep(15.0000000001)).toBe(15);
expect(ceilToStep(15.01)).toBe(20);
});
});
describe('estimateLeg', () => {
it('걸어서는 직선거리 × 1.3 ÷ 분당 70m 다(700m → 13분 → 15분)', () => {
const leg = estimateLeg(CENTER, offset(700, 0), 'walk');
expect(leg).toEqual({ minutes: 15, meters: 700, mode: 'walk' });
expect(isLegWithinLimit(leg, 'walk')).toBe(true);
});
it('걸어서 15분을 넘는 구간은 최대 이동 시간을 넘는다(810m → 15.04분 → 20분)', () => {
const leg = estimateLeg(CENTER, offset(810, 0), 'walk');
expect(leg.minutes).toBe(20);
expect(isLegWithinLimit(leg, 'walk')).toBe(false);
});
it('대중교통은 10분 + 직선거리 × 1.4 ÷ 분당 300m 다(3km → 24분 → 25분)', () => {
const leg = estimateLeg(CENTER, offset(0, 3000), 'transit');
expect(leg).toEqual({ minutes: 25, meters: 3000, mode: 'transit' });
expect(isLegWithinLimit(leg, 'transit')).toBe(true);
});
it('대중교통·자동차라도 가까우면 걷는다', () => {
expect(estimateLeg(CENTER, offset(300, 0), 'transit')).toEqual({
minutes: 10,
meters: 300,
mode: 'walk',
});
expect(estimateLeg(CENTER, offset(200, 0), 'car')).toEqual({
minutes: 5,
meters: 200,
mode: 'walk',
});
});
it('자동차는 5분 + 직선거리 × 1.4 ÷ 분당 400m 이고 30분을 넘으면 너무 멀다(10km → 40분)', () => {
const leg = estimateLeg(CENTER, offset(10_000, 0), 'car');
expect(leg.mode).toBe('car');
expect(leg.minutes).toBe(40);
expect(isLegWithinLimit(leg, 'car')).toBe(false);
});
});
@@ -0,0 +1,50 @@
/**
* 장소 사이 이동 시간 추정(설계 §3.6) — MVP 는 경로 API 없이 직선거리로 계산하고 화면에 '약 N분'으로 보인다.
*/
import { distanceMeters, type LatLng } from '@/shared/utils/geo';
import {
MAX_LEG_MINUTES,
type Mobility,
TIME_STEP_MINUTES,
TRAVEL_MODELS,
} from './planner.constants';
import type { Leg } from './planner.types';
/** 부동소수점 오차로 15.000001분이 20분으로 올라가지 않게 빼는 아주 작은 값 */
const EPSILON = 1e-9;
/** 시간표 단위(5분)로 올린다 */
export function ceilToStep(minutes: number, step = TIME_STEP_MINUTES): number {
return Math.max(0, Math.ceil(minutes / step - EPSILON) * step);
}
function estimateMinutes(meters: number, mode: Mobility): number {
const model = TRAVEL_MODELS[mode];
return (
model.fixedMinutes + (meters * model.detourFactor) / model.metersPerMinute
);
}
/**
* 두 곳 사이 이동(추정). 시간은 5분 단위로 올린다.
* 대중교통·자동차라도 걷는 편이 빠른 가까운 거리(대중교통 약 700m · 자동차 약 300m 미만)는 걷기로 둔다.
*/
export function estimateLeg(from: LatLng, to: LatLng, mobility: Mobility): Leg {
const meters = distanceMeters(from, to);
let mode = mobility;
let minutes = estimateMinutes(meters, mobility);
if (mobility !== 'walk') {
const walk = estimateMinutes(meters, 'walk');
if (walk <= minutes) {
mode = 'walk';
minutes = walk;
}
}
return { minutes: ceilToStep(minutes), meters: Math.round(meters), mode };
}
/** 구간이 이동 수단의 최대 시간(걸어서 15분 · 대중교통·자동차 30분) 이하인가 */
export function isLegWithinLimit(leg: Leg, mobility: Mobility): boolean {
return leg.minutes <= MAX_LEG_MINUTES[mobility];
}
@@ -22,6 +22,13 @@ export class EventListItemDto {
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-12-06' })
endDate!: string;
@ApiProperty({
description: '장르(연극·뮤지컬·클래식·전시 …)',
nullable: true,
example: '뮤지컬',
})
genre!: string | null;
@ApiProperty({ description: '오늘(한국 날짜) 진행 중인지', example: false })
ongoing!: boolean;
@@ -87,6 +94,9 @@ export class EventDetailDto extends EventListItemDto {
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '예매·관람 안내 링크', nullable: true })
bookingUrl!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@@ -20,12 +20,11 @@ import type { EventKind } from '../place.constants';
/** 기간 기본값·한도(일) */
export const EVENT_RANGE_DEFAULT_DAYS = 30;
export const EVENT_RANGE_MAX_DAYS = 92;
/** 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의) */
export const EVENT_MAX_SPAN_DAYS = 60;
/** 근처 행사 반경(m) */
export const EVENT_RADIUS_DEFAULT_M = 5_000;
export const EVENT_RADIUS_MIN_M = 100;
export const EVENT_RADIUS_MAX_M = 50_000;
/** 대구 근교(경북) 행사까지 거리순으로 보려면 100km 이상이 필요하다(포항 약 85km · 청송 약 100km) */
export const EVENT_RADIUS_MAX_M = 150_000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -57,7 +56,7 @@ export class EventSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사',
'분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사 · exhibition 전시',
enum: EVENT_KINDS,
isArray: true,
example: 'festival,event',
@@ -123,4 +123,14 @@ export class PlaceSearchQueryDto extends CursorPaginationQueryDto {
@Transform(toBoolean)
@IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' })
indoor?: boolean;
@ApiPropertyOptional({
description:
'관광공사 장소와 운영자가 확인한 장소만 — 확인 안 된 상가정보 매장과 운영자가 추천하지 않기로 한 곳을 뺀다(장소 상세의 함께 가기 좋은 곳)',
example: true,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'trusted 는 true 또는 false 로 입력해 주세요.' })
trusted?: 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;
}
@@ -7,26 +7,37 @@ import {
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import {
EVENT_KINDS,
EVENT_SOURCES,
EVENT_STATUSES,
IMAGE_LICENSES,
sqlIn,
} from '../place.constants';
import type { EventKind, EventStatus, ImageLicense } from '../place.constants';
import type {
EventKind,
EventSource,
EventStatus,
ImageLicense,
} from '../place.constants';
import { EventVenue } from './event-venue.entity';
import { Place } from './place.entity';
/**
* 축제·공연·행사(관광공사 searchFestival2). 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "60일 넘는 상시 프로그램 제외"는 저장할 때가 아니라 조회할 때 적용한다.
* 축제·공연·행사·전시. 출처는 관광공사(searchFestival2)·KOPIS(공연)·한국문화정보원(전시) 등이다.
* 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "상시 프로그램 제외"(종류별 기간 상한, EVENT_MAX_SPAN_DAYS)는 저장할 때가 아니라 조회할 때 적용한다.
*/
@Entity({ name: 'festival_event' })
@Unique('uq_festival_event_source', ['source', 'sourceId'])
@Index('ix_festival_event_period', ['startDate', 'endDate'])
@Index('ix_festival_event_lat_lng', ['lat', 'lng'])
@Index('ix_festival_event_region', ['ldongRegnCd'])
@Check('ck_festival_event_source', sqlIn('source', EVENT_SOURCES))
@Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS))
@Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES))
@Check(
@@ -38,17 +49,36 @@ export class FestivalEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 관광공사 contentid */
@Column({ type: 'text', unique: true })
@Column({ type: 'text', default: 'tour' })
source!: EventSource;
/** 원본 id — 관광공사 contentid · KOPIS mt20id · 문화정보원 seq. (source, sourceId) 가 유니크 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
kind!: EventKind;
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제) */
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제 · KOPIS 장르코드 · 문화정보원 realmCode) */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
/** 장르 표시용(연극·뮤지컬·클래식·전시 …) */
@Column({ type: 'text', nullable: true })
genre!: string | null;
/** 예매·관람 안내 링크 */
@Column({ type: 'text', nullable: true })
bookingUrl!: string | null;
/** 공연장·전시장(좌표를 공연장에서 얻는 출처가 있다 — KOPIS 공연시설) */
@Column({ type: 'uuid', nullable: true })
venueId!: string | null;
@ManyToOne(() => EventVenue, { nullable: true })
@JoinColumn({ name: 'venue_id' })
venue?: EventVenue | null;
@Column({ type: 'text' })
name!: string;
+19 -24
View File
@@ -7,36 +7,29 @@ import {
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { addDaysIso, daysBetweenIso } from '@/shared/utils/iso-date';
import { kstDateString } from '@/shared/utils/kst';
import { EventDetailDto, EventListItemDto } from './dto/event-list-item.dto';
import {
EVENT_MAX_SPAN_DAYS,
EVENT_RADIUS_DEFAULT_M,
EVENT_RANGE_DEFAULT_DAYS,
EVENT_RANGE_MAX_DAYS,
EventSearchQueryDto,
} from './dto/event-search-query.dto';
import { FestivalEvent } from './entities/festival-event.entity';
import type { EventSource } from './place.constants';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
import { boundingBox, sqlDistanceM, sqlEventWithinMaxSpan } from './place.sql';
const DAY_MS = 86_400_000;
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/** 'YYYY-MM-DD' 두 날짜 사이 일수(b − a). 형식이 틀리거나 없는 날짜면 NaN */
function daysBetween(a: string, b: string): number {
const ta = Date.parse(`${a}T00:00:00Z`);
const tb = Date.parse(`${b}T00:00:00Z`);
const validA = new Date(ta).toISOString().startsWith(a);
const validB = new Date(tb).toISOString().startsWith(b);
return validA && validB ? Math.round((tb - ta) / DAY_MS) : Number.NaN;
}
function addDays(iso: string, days: number): string {
return new Date(Date.parse(`${iso}T00:00:00Z`) + days * DAY_MS)
.toISOString()
.slice(0, 10);
}
/** 출처 표기(행사 상세 하단·앱 정보 화면) */
const PROVIDER_LABEL: Record<EventSource, string> = {
tour: '한국관광공사',
kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)',
kcisa: '한국문화정보원 문화포털',
dgfca: '대구문화예술진흥원',
};
/**
* 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'.
@@ -55,9 +48,10 @@ export class FestivalService {
): 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)) {
// 끝을 생략하면 시작일 + 기본 일수. 시작일이 없는 날짜("2030-13-01")면 null 이 되어 아래에서 VAL_001 로 거른다
const to = query.to ?? addDaysIso(from, EVENT_RANGE_DEFAULT_DAYS);
const span = to === null ? Number.NaN : daysBetweenIso(from, to);
if (to === null || !(span >= 0 && span <= EVENT_RANGE_MAX_DAYS)) {
throw new BusinessException('VAL_001', {
context: { reason: '기간 오류', from, to },
});
@@ -83,9 +77,8 @@ export class FestivalService {
.createQueryBuilder('e')
.where('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :from AND e.start_date <= :to', { from, to })
.andWhere('(e.end_date - e.start_date) <= :maxSpan', {
maxSpan: EVENT_MAX_SPAN_DAYS,
});
// 상시 프로그램 제외 — 종류별 기간 상한(전시는 길게)
.andWhere(sqlEventWithinMaxSpan('e'));
if (query.kind?.length) {
qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind });
}
@@ -143,9 +136,10 @@ export class FestivalService {
playtimeText: event.playtimeText,
homepage: event.homepage,
phone: event.phone,
bookingUrl: event.bookingUrl,
imageUrl: event.imageUrl,
attribution: {
provider: '한국관광공사',
provider: PROVIDER_LABEL[event.source],
asOf: event.sourceModifiedAt
? new Date(event.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
@@ -164,6 +158,7 @@ export class FestivalService {
id: e.id,
name: e.name,
kind: e.kind,
genre: e.genre,
startDate: e.startDate,
endDate: e.endDate,
ongoing: e.startDate <= today && today <= e.endDate,
+38 -2
View File
@@ -75,11 +75,34 @@ export type I18nOrigin = (typeof I18N_ORIGINS)[number];
export const IMAGE_LICENSES = ['Type1', 'Type3'] as const;
export type ImageLicense = (typeof IMAGE_LICENSES)[number];
/** 관광공사 lclsSystm2: EV01 축제 · EV02 공연 · EV03 행사 */
export const EVENT_KINDS = ['festival', 'performance', 'event'] as const;
/** 행사 종류 — 관광공사 lclsSystm2(EV01 축제 · EV02 공연 · EV03 행사) + 전시(KOPIS 장르는 genre 에 따로 둔다) */
export const EVENT_KINDS = [
'festival',
'performance',
'event',
'exhibition',
] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
export const EVENT_STATUSES = ['active', 'hidden'] as const;
/**
* 행사 출처 — tour 한국관광공사 · kopis 공연예술통합전산망 · kcisa 한국문화정보원 · dgfca 대구문화예술진흥원.
* (출처, 원본 id) 가 행사·공연장의 연결 키다.
*/
export const EVENT_SOURCES = ['tour', 'kopis', 'kcisa', 'dgfca'] as const;
export type EventSource = (typeof EVENT_SOURCES)[number];
/**
* 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의는 60일).
* 전시는 몇 달씩 하는 것이 보통이라 길게 잡는다 — 그대로 두면 전시 대부분이 빠진다.
*/
export const EVENT_MAX_SPAN_DAYS: Record<EventKind, number> = {
festival: 60,
performance: 60,
event: 60,
exhibition: 183,
};
export type EventStatus = (typeof EVENT_STATUSES)[number];
export const SYNC_JOBS = [
@@ -89,6 +112,8 @@ export const SYNC_JOBS = [
'store',
'event',
'closure',
'kopis',
'kcisa',
] as const;
export type SyncJob = (typeof SYNC_JOBS)[number];
@@ -120,6 +145,17 @@ export const MALL_NAME_PREFIXES = [
'동아백화점',
] as const;
/**
* 원본 좌표가 실제 위치가 아닌 관광공사 장소(contentid → 사유) — 원본이 고쳐질 때까지 받지 않는다(목록에서 숨겨진다).
* 주소의 시·도와 맞는지만 보는 검사로는 못 거르는 곳만 둔다. 실제 좌표를 확인하면 큐레이션으로 되살린다.
*/
export const TOUR_WRONG_LOCATION_IDS: Readonly<Record<string, string>> = {
// 2026-11-04 회귀 점검: 주소·좌표 모두 운영 사무실(수성구 신천동로86안길 14-6)이라 김광석길에서 '도보 7분'으로 보였다. 코스는 팔공산(동구)
'1008532': '[대구올레 팔공산 3코스] 부인사 도보길',
'1008588': '[대구올레 팔공산 4코스] 평광동 왕건길',
'1008614': '[대구올레 팔공산 5코스] 구암마을 가는 길',
};
/**
* 같은 가게를 관광공사·상가정보가 둘 다 가지고 있을 때(2026-11-02 대구 약 21곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventVenue } from './entities/event-venue.entity';
import { FestivalEvent } from './entities/festival-event.entity';
import { PlaceCuration } from './entities/place-curation.entity';
import { Place } from './entities/place.entity';
@@ -24,6 +25,7 @@ import { PlaceService } from './place.service';
PlaceI18n,
PlaceListing,
FestivalEvent,
EventVenue,
PlaceSyncRun,
]),
],
+11 -2
View File
@@ -88,6 +88,15 @@ export class PlaceService {
settings: ['indoor', 'both'],
});
}
if (query.trusted) {
// 확인 안 된 상가정보 매장이 추천처럼 보이지 않게 뺀다(2026-11-02 합의). 앱이 받아서 거르면
// 도심처럼 상가가 빽빽한 곳에서는 가까운 몇 곳이 모두 상가라 칸이 비었다(2026-11-04 회귀 점검)
qb.andWhere('(v.source = :trustedSource OR v.verified_at IS NOT NULL)', {
trustedSource: 'tour',
}).andWhere('(v.verdict IS NULL OR v.verdict <> :notRecommend)', {
notRecommend: 'not_recommend',
});
}
// 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다(그러면 결과 객체가 비어 버린다)
let sortKey = '(v.name)';
@@ -210,7 +219,7 @@ export class PlaceService {
private toListItem(
v: PlaceListing,
distance: number | null,
clock: { weekday: number; minutes: number },
clock: { date: string; minutes: number },
): PlaceListItemDto {
return {
id: v.id,
@@ -228,7 +237,7 @@ export class PlaceService {
imageThumbUrl: v.imageThumbUrl,
imageLicense: v.imageLicense,
hoursOrigin: v.hoursOrigin,
openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null,
openNow: v.hours ? isOpenAt(v.hours, clock.date, clock.minutes) : null,
petPolicy: v.petPolicy,
petOrigin: v.petOrigin,
setting: v.setting,
+12
View File
@@ -1,6 +1,7 @@
/**
* 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다.
*/
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
/** 위도 1도의 거리(m) */
export const METERS_PER_LAT_DEGREE = 111_320;
@@ -39,3 +40,14 @@ export function boundingBox(
lngMax: lng + dLng,
};
}
/**
* 종류별 기간 상한 안의 행사인가(상시 프로그램 제외) — 상수(EVENT_MAX_SPAN_DAYS)로만 만드는 SQL 조건.
* @param alias festival_event 별칭
*/
export function sqlEventWithinMaxSpan(alias: string): string {
const cases = Object.entries(EVENT_MAX_SPAN_DAYS)
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
.join(' ');
return `(${alias}.end_date - ${alias}.start_date) <= CASE ${alias}.kind ${cases} END`;
}
@@ -0,0 +1,118 @@
import {
DedupeCandidate,
dropSameVenueDuplicates,
isSameVenueDuplicate,
isSameVenueSameName,
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']);
});
});
describe('isSameVenueSameName', () => {
const show: DedupeCandidate = {
sourceId: 'PF300322',
name: '포항국제음악제, 포항시립교향악단 with 김영욱 & 율리안 슈테켈',
eventPlace: '포항문화예술회관',
startDate: '2026-11-10',
endDate: '2026-11-10',
};
it('같은 장소·기간·제목이면 같은 공연(일정변경 알림을 뗀 재등록)', () => {
expect(
isSameVenueSameName(
{ ...show, name: '데뷔 60주년 기념공연, 남진 전국투어 콘서트' },
{
...show,
sourceId: 'PF298257',
name: '데뷔 60주년 기념공연, 남진 전국투어 콘서트',
},
),
).toBe(true);
});
it('음악제 이름만 같은 같은 날 다른 공연은 합치지 않는다(2026-11-05 포항국제음악제)', () => {
const other = {
...show,
sourceId: 'PF302934',
name: '포항국제음악제, 아티스트 포항 Ⅱ. 문성우',
};
expect(isSameVenueDuplicate(show, other)).toBe(true); // 전시용 기준이면 합쳐진다
expect(isSameVenueSameName(show, other)).toBe(false);
expect(
dropSameVenueDuplicates([show, other], isSameVenueSameName).dropped,
).toEqual([]);
});
});
@@ -0,0 +1,96 @@
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
);
}
/**
* 같은 장소(이름 비교 키) · 같은 시작·종료일 · 제목 비교 키까지 같으면 같은 행사 — KOPIS 용 엄격한 기준.
* 공연은 음악제·시리즈 이름을 제목 앞에 붙여 같은 날 같은 공연장의 다른 공연도 6글자 넘게 겹친다
* (2026-11-05 "포항국제음악제, 아티스트 포항 Ⅱ. 문성우" / "포항국제음악제, 포항시립교향악단 with …").
* KOPIS 의 실제 중복("[일정변경]" 재등록)은 알림을 떼면 제목이 같다.
*/
export function isSameVenueSameName(
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;
const nameA = eventNameKey(a.name);
return nameA !== '' && nameA === eventNameKey(b.name);
}
/**
* 같은 행사로 보이는 것 중 먼저 등록된 것(원본 id 숫자가 작은 것)만 남긴다.
* 숫자가 아닌 id 는 문자열 순서로 비교한다. 같은 행사 판단은 [isSame](기본은 제목 6글자 겹침)으로 한다.
*/
export function dropSameVenueDuplicates<T extends DedupeCandidate>(
rows: readonly T[],
isSame: (
a: DedupeCandidate,
b: DedupeCandidate,
) => boolean = isSameVenueDuplicate,
): { 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) => isSame(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, '')
);
}
@@ -52,8 +52,9 @@ describe('festivalItemToEvent', () => {
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',
// 관광공사 사진 서버는 https 로 맞춘다
imageUrl: 'https://tong.visitkorea.or.kr/f.jpg',
imageThumbUrl: 'https://tong.visitkorea.or.kr/f_s.jpg',
imageLicense: 'Type3',
sourceModifiedAt: new Date('2026-10-20T01:15:00.000Z'),
});
@@ -1,5 +1,6 @@
import { isInKorea } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { preferHttps } from '@/shared/utils/image-url';
import { parseKstTimestamp } from '@/shared/utils/kst';
import { IMAGE_LICENSES } from '../place.constants';
import type { EventKind, ImageLicense } from '../place.constants';
@@ -88,8 +89,8 @@ export function festivalItemToEvent(
ldongRegnCd: str(item, 'lDongRegnCd'),
ldongSignguCd: str(item, 'lDongSignguCd'),
phone: htmlToText(item.tel),
imageUrl: str(item, 'firstimage'),
imageThumbUrl: str(item, 'firstimage2'),
imageUrl: preferHttps(str(item, 'firstimage')),
imageThumbUrl: preferHttps(str(item, 'firstimage2')),
imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '')
? (license as ImageLicense)
: null,
@@ -16,6 +16,12 @@ import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
/**
* 행사를 받을 시·도 — 대구(27)만으로는 90일에 1건뿐이라(2026-11-03 실측: 대구 1 · 경북 7) 근교 경북(47)까지 받는다.
* 화면은 동네 좌표에서 거리순으로 보여 주므로 대구 행사가 먼저 나온다.
*/
export const DEFAULT_EVENT_REGIONS = ['27', '47'];
/** 오늘부터 며칠 안에 열리는 행사를 받을지 */
export const DEFAULT_EVENT_LOOKAHEAD_DAYS = 90;
/** 관광공사 축제·공연·행사 관광타입 */
@@ -85,6 +91,18 @@ export class FestivalSync {
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<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;
@@ -123,6 +141,7 @@ export class FestivalSync {
await this.syncDetails(api, budget, regionCode, window.from, result);
}
result.detailRemaining = await this.events.countBy({
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
@@ -155,7 +174,9 @@ export class FestivalSync {
result: FestivalSyncResult,
): Promise<void> {
const runStart = new Date();
// 관광공사 행사만 다룬다(KOPIS 공연 등 다른 출처 행사는 각자의 동기화가 관리한다)
const existing = await this.events.find({
where: { source: 'tour' },
select: {
id: true,
sourceId: true,
@@ -211,6 +232,7 @@ export class FestivalSync {
mapped.sourceModifiedAt?.getTime();
rows.push({
...mapped,
source: 'tour',
ldongRegnCd: mapped.ldongRegnCd ?? regionCode,
status: 'active',
lastSeenAt: runStart,
@@ -249,7 +271,8 @@ export class FestivalSync {
.createQueryBuilder()
.update(FestivalEvent)
.set({ status: 'hidden' })
.where('ldong_regn_cd = :region', { region: regionCode })
.where('source = :source', { source: 'tour' })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
@@ -270,7 +293,7 @@ export class FestivalSync {
.insert()
.into(FestivalEvent)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source_id'])
.orUpdate(overwrite, ['source', 'source_id'])
.execute();
}
}
@@ -284,6 +307,7 @@ export class FestivalSync {
): Promise<void> {
const pending = await this.events.find({
where: {
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
@@ -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,259 @@
import {
cleanKopisTitle,
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('[대구]');
});
});
describe('cleanKopisTitle', () => {
it('제목 앞의 [일정변경] 알림과 끝의 지역 꼬리표를 함께 뗀다(2026-11-04 실제 제목)', () => {
expect(
cleanKopisTitle(
'[일정변경] 데뷔 60주년 기념공연, 남진 전국투어 콘서트 [대구 북구]',
),
).toBe('데뷔 60주년 기념공연, 남진 전국투어 콘서트');
expect(cleanKopisTitle('[일정 변경] 굿프렌즈')).toBe('굿프렌즈');
});
it('다른 머리 꼬리표와 알림뿐인 제목은 그대로 둔다', () => {
expect(cleanKopisTitle('[패키지] 더 피아니스트')).toBe(
'[패키지] 더 피아니스트',
);
expect(cleanKopisTitle('[일정변경]')).toBe('[일정변경]');
});
});
@@ -0,0 +1,257 @@
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();
}
/** 제목 앞의 일정 변경 알림 — "[일정변경] 데뷔 60주년 기념공연 …" */
const SCHEDULE_NOTICE_RE = /^\s*\[\s*일정\s*변경\s*\]\s*/;
/**
* 제목을 정리한다 — 앞의 일정 변경 알림과 끝의 지역 꼬리표를 뗀다.
* KOPIS 는 일정이 바뀐 공연 제목에 "[일정변경]" 을 붙이고 새 id 로 다시 등록한다(2026-11-04 대구 1쌍).
* 화면에는 바뀐 날짜가 이미 보이므로 알림은 떼고, 같은 공연 두 건은 중복 제거(event-dedupe)가 하나로 줄인다.
*/
export function cleanKopisTitle(title: string): string {
const withoutNotice = title.replace(SCHEDULE_NOTICE_RE, '');
return stripRegionTag(withoutNotice === '' ? title : withoutNotice);
}
/** 목록 항목 1건 → 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function kopisListItemToEvent(
item: KopisItem,
): KopisListEvent | { skip: KopisSkipReason } {
const sourceId = str(item, 'mt20id');
const title = htmlToText(item.prfnm);
const name = title ? cleanKopisTitle(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 ? cleanKopisTitle(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,613 @@
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 { dropSameVenueDuplicates, isSameVenueSameName } from './event-dedupe';
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'
| 'duplicate_same_source';
/** 목록에서 만든 행 — 중복 판단에 이름·장소·기간이 필요하다 */
type KopisRow = Partial<FestivalEvent> & {
sourceId: string;
name: string;
eventPlace: string | null;
startDate: string;
endDate: string;
};
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 listed = new Map<string, KopisRow>();
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 (listed.has(mapped.sourceId)) {
this.skip(result, 'duplicate'); // 쪽 경계에서 같은 공연이 두 번 올 수 있다
continue;
}
listed.set(mapped.sourceId, {
...columns,
source: SOURCE,
kind: 'performance',
ldongRegnCd: regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
});
}
if (items.length < PAGE_SIZE) {
complete = true;
break;
}
}
// 일정이 바뀐 공연을 새 id 로 다시 등록하면("[일정변경] …") 같은 공연장·같은 기간에 두 건이 남는다.
// 먼저 등록된 것만 남기고 나머지는 넣지 않아 아래에서 숨긴다(event-dedupe.ts, 2026-11-04 회귀 점검).
// 제목이 같을 때만 합친다 — 음악제 이름을 앞에 붙인 같은 날 다른 공연을 합치지 않게(isSameVenueSameName)
const { kept, dropped } = dropSameVenueDuplicates(
[...listed.values()],
isSameVenueSameName,
);
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;
}
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);
}
}
@@ -1,11 +1,14 @@
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';
@@ -16,7 +19,7 @@ import { StoreSync } from './store.sync';
import { TourPetSync } from './tour-pet.sync';
/**
* 장소 동기화 — 공공데이터를 place 에 적재한다.
* 장소·행사 동기화 — 공공데이터를 place·festival_event 에 적재한다.
* API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다.
*/
@Module({
@@ -26,6 +29,7 @@ import { TourPetSync } from './tour-pet.sync';
PlaceCuration,
PlaceSyncRun,
FestivalEvent,
EventVenue,
]),
],
providers: [
@@ -35,6 +39,8 @@ import { TourPetSync } from './tour-pet.sync';
TourPetSync,
StoreSync,
FestivalSync,
KopisSync,
KcisaSync,
PlaceDedupe,
PlaceHoursReparse,
PlaceSyncStatus,
@@ -42,6 +48,8 @@ import { TourPetSync } from './tour-pet.sync';
exports: [
TourKorSync,
FestivalSync,
KopisSync,
KcisaSync,
TourPetSync,
StoreSync,
PlaceDedupe,
@@ -1,7 +1,7 @@
import { BudgetExceededError, CallBudget } from './call-budget';
/**
* 공공데이터포털(apis.data.go.kr) 공통 호출 규칙 — 관광공사·상가정보 클라이언트가 함께 쓴다.
* 공공데이터 공통 호출 규칙 — 관광공사·상가정보(apis.data.go.kr)·KOPIS 클라이언트가 함께 쓴다.
* 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다), 간격을 두고, 네트워크 오류·5xx 만 다시 시도한다.
* **키는 어떤 오류 메시지에도 남기지 않는다.** 응답 해석은 API 마다 달라 하위 클래스가 맡는다.
*/
@@ -9,7 +9,7 @@ import { BudgetExceededError, CallBudget } from './call-budget';
const REQUEST_TIMEOUT_MS = 20_000;
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1_000;
const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
const DEFAULT_CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
/** 게이트웨이가 한도 초과로 거부할 때의 표시(오류 코드 22) */
export const QUOTA_EXCEEDED_RE =
@@ -42,6 +42,18 @@ export interface PublicDataClientOptions {
sleep?: (ms: number) => Promise<void>;
}
/** API 마다 다른 호출 규칙 — 키 파라미터 이름, 호출 간격 */
export interface PublicDataClientSettings {
/** 키를 싣는 쿼리 파라미터(공공데이터포털 serviceKey · KOPIS service) */
keyParam?: string;
/** 연속 호출 최소 간격(ms) */
intervalMs?: number;
/** 호출 하나의 제한 시간(ms) — 사용자 요청 중에 부르는 API(날씨)는 짧게 둔다 */
timeoutMs?: number;
/** 네트워크 오류·5xx 때 몇 번까지 시도할지(처음 호출 포함) */
maxAttempts?: number;
}
const defaultSleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
@@ -78,40 +90,54 @@ export function parseJsonOrThrow(body: string, operation: string): unknown {
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 readonly timeoutMs: number;
private readonly maxAttempts: 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;
this.timeoutMs = settings.timeoutMs ?? REQUEST_TIMEOUT_MS;
this.maxAttempts = settings.maxAttempts ?? MAX_ATTEMPTS;
}
/** 응답 본문 → 페이지. 결과 코드가 오류면 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({
serviceKey: this.options.serviceKey,
[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}/${operation}?${query.toString()}`;
const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
this.options.budget.take(operation);
await this.pace();
try {
const res = await this.fetchImpl(url, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(this.timeoutMs),
});
const body = await res.text();
if (res.status >= 500) {
@@ -135,19 +161,21 @@ export abstract class PublicDataClient<Page> {
}
lastError = error; // 네트워크 오류·시간 초과 → 다시 시도
}
if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt);
if (attempt < this.maxAttempts) {
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}`,
`${operation}: ${this.maxAttempts}회 시도 실패 — ${reason}`,
),
);
}
private async pace(): Promise<void> {
const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now();
const wait = this.lastCallAt + this.intervalMs - Date.now();
if (wait > 0) await this.sleep(wait);
this.lastCallAt = Date.now();
}
@@ -3,6 +3,8 @@ 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';
@@ -10,11 +12,18 @@ import { TourApi, TourApiClient } from './tour-api.client';
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()
@@ -42,12 +51,41 @@ export class PublicDataApiFactory {
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 {
const key = this.config.get<string>('PUBLIC_DATA_SERVICE_KEY')?.trim();
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(
'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요',
`${name} 가 설정되지 않았습니다 — backend/.env.{환경} 에 ${description}를 넣으세요`,
);
}
return key;
@@ -70,8 +70,9 @@ describe('listItemToPlace', () => {
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',
// 관광공사 사진 서버는 https 로 맞춘다(웹에서 혼합 콘텐츠로 막히지 않게)
imageUrl: 'https://tong.visitkorea.or.kr/a.jpg',
imageThumbUrl: 'https://tong.visitkorea.or.kr/a_s.jpg',
imageLicense: 'Type1',
sourceModifiedAt: new Date('2026-10-15T00:30:00.000Z'),
});
@@ -89,6 +90,39 @@ describe('listItemToPlace', () => {
});
});
it('좌표가 주소의 시·도 밖이면 건너뛴다(오천서원 — 주소 대구 수성구 · 좌표 포항)', () => {
expect(
listItemToPlace(
item({
contentid: '1955582',
title: '오천서원',
addr1: '대구광역시 수성구 파동로2안길 20',
mapx: '129.3976973279',
mapy: '35.9819418183',
lDongSignguCd: '260',
}),
),
).toEqual({ skip: 'coords_mismatch' });
// 주소가 비면 법정동 시·도 코드로 확인한다
expect(
listItemToPlace(item({ addr1: '', mapx: '129.39', mapy: '35.98' })),
).toEqual({ skip: 'coords_mismatch' });
});
it('좌표가 실제 위치가 아닌 것으로 확인한 장소는 건너뛴다(대구올레 팔공산 3코스)', () => {
expect(
listItemToPlace(
item({
contentid: '1008532',
title: '[대구올레 팔공산 3코스] 부인사 도보길',
addr1: '대구광역시 수성구 신천동로86안길 14-6',
mapx: '128.61234',
mapy: '35.86191',
}),
),
).toEqual({ skip: 'wrong_location' });
});
it('알 수 없는 저작권 유형은 비우고, 주소 두 줄은 이어 붙인다', () => {
const p = listItemToPlace(
item({ cpyrhtDivCd: 'Type9', addr2: '(남산동)' }),
+22 -5
View File
@@ -1,9 +1,10 @@
import { isInKorea } from '@/shared/utils/geo';
import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo';
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
import { preferHttps } from '@/shared/utils/image-url';
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 { IMAGE_LICENSES, TOUR_WRONG_LOCATION_IDS } from '../place.constants';
import type {
ImageLicense,
PlaceCategory,
@@ -167,7 +168,19 @@ export interface TourListPlace {
sourceModifiedAt: Date | null;
}
export type SkipReason = 'not_place' | 'no_category' | 'bad_coords' | 'no_id';
/**
* - bad_coords: 좌표가 없거나 한국 밖
* - coords_mismatch: 좌표가 주소의 시·도와 다른 곳(2026-11-04 '오천서원' — 주소 대구 수성구 · 좌표 포항)
* - wrong_location: 좌표가 실제 위치가 아닌 것으로 확인한 장소(TOUR_WRONG_LOCATION_IDS)
* 건너뛴 장소는 저장하지 않으므로, 이미 있던 장소는 목록을 끝까지 받은 뒤 숨겨진다.
*/
export type SkipReason =
| 'not_place'
| 'no_category'
| 'bad_coords'
| 'coords_mismatch'
| 'wrong_location'
| 'no_id';
/** 목록(areaBasedList2) 항목 1건 → place 기본 컬럼. 장소가 아니거나 쓸 수 없으면 사유를 돌려준다 */
export function listItemToPlace(
@@ -190,11 +203,15 @@ export function listItemToPlace(
if (!isInKorea(lat, lng)) {
return { skip: 'bad_coords' };
}
if (TOUR_WRONG_LOCATION_IDS[sourceId]) return { skip: 'wrong_location' };
const license = str(item, 'cpyrhtDivCd');
const address = [str(item, 'addr1'), str(item, 'addr2')]
.filter(Boolean)
.join(' ');
// 거리·지도가 엉뚱한 곳을 가리키지 않게, 주소(없으면 법정동 코드)의 시·도 밖 좌표는 받지 않는다
const sido = sidoCodeFromAddress(address) ?? str(item, 'lDongRegnCd');
if (sido && !isInSido(sido, lat, lng)) return { skip: 'coords_mismatch' };
return {
source: 'tour',
sourceId,
@@ -208,8 +225,8 @@ export function listItemToPlace(
ldongRegnCd: str(item, 'lDongRegnCd'),
ldongSignguCd: str(item, 'lDongSignguCd'),
phone: htmlToText(item.tel),
imageUrl: str(item, 'firstimage'),
imageThumbUrl: str(item, 'firstimage2'),
imageUrl: preferHttps(str(item, 'firstimage')),
imageThumbUrl: preferHttps(str(item, 'firstimage2')),
imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '')
? (license as ImageLicense)
: null,
@@ -0,0 +1,108 @@
import {
BudgetExceededError,
CallBudget,
} from '@/modules/place/sync/call-budget';
import {
type FetchLike,
PublicDataApiError,
} from '@/modules/place/sync/public-data.client';
import { KmaForecastClient, parseKmaPage } from './kma-forecast.client';
const KEY = 'abc+def/ghi==';
const body = (resultCode: string, item?: unknown, totalCount = 1) =>
JSON.stringify({
response: {
header: { resultCode, resultMsg: 'X' },
body: item === undefined ? undefined : { items: { item }, totalCount },
},
});
function client(
responses: Array<{ status?: number; body?: string; throws?: Error }>,
) {
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 KmaForecastClient({
serviceKey: KEY,
budget: new CallBudget(100),
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls };
}
describe('parseKmaPage', () => {
it('정상(00)이면 항목을, 03(NODATA)이면 자료 없음을 돌려준다', () => {
const item = {
category: 'POP',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '60',
};
expect(parseKmaPage(body('00', [item], 1), 'getVilageFcst')).toEqual({
items: [item],
totalCount: 1,
noData: false,
});
expect(parseKmaPage(body('03'), 'getVilageFcst')).toEqual({
items: [],
totalCount: 0,
noData: true,
});
});
it('22 는 한도 초과, 나머지 코드는 오류다', () => {
expect(() => parseKmaPage(body('22'), 'getVilageFcst')).toThrow(
BudgetExceededError,
);
expect(() => parseKmaPage(body('10'), 'getVilageFcst')).toThrow(
/getVilageFcst: 결과 코드 10/,
);
});
});
describe('KmaForecastClient', () => {
it('발표 시각·격자·JSON 을 실어 부른다', async () => {
const { api, urls } = client([{ body: body('00', [], 0) }]);
await api.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 });
const url = new URL(urls[0]);
expect(url.pathname).toBe(
'/1360000/VilageFcstInfoService_2.0/getVilageFcst',
);
expect(Object.fromEntries(url.searchParams)).toMatchObject({
serviceKey: KEY,
dataType: 'JSON',
base_date: '20261108',
base_time: '1100',
nx: '89',
ny: '90',
});
});
it('입력 화면을 막지 않게 두 번까지만 시도하고, 오류에 키를 남기지 않는다', async () => {
const { api, urls } = client([
{ throws: new Error(`connect ETIMEDOUT ${KEY}`) },
{ throws: new Error('connect ETIMEDOUT') },
]);
const error = await api
.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 })
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(PublicDataApiError);
expect((error as Error).message).toContain('2회 시도 실패');
expect((error as Error).message).not.toContain(KEY);
expect(urls).toHaveLength(2);
});
});
@@ -0,0 +1,102 @@
/**
* 기상청 단기예보 조회서비스(공공데이터포털 VilageFcstInfoService_2.0) 호출.
* 공공데이터 공통 클라이언트(키 가리기·한도·재시도)를 쓰되, 사용자 요청 중에 부르므로 제한 시간을 짧게 둔다.
*/
import { BudgetExceededError } from '@/modules/place/sync/call-budget';
import {
asRecord,
parseJsonOrThrow,
PublicDataApiError,
PublicDataClient,
type PublicDataClientOptions,
type PublicDataItem,
toItems,
} from '@/modules/place/sync/public-data.client';
import type { KmaGrid } from './kma-grid';
import { issueParams, type KmaIssue } from './kma-forecast';
export const KMA_FORECAST_BASE_URL =
'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0';
export const KMA_FORECAST_OPERATION = 'getVilageFcst';
/** 한 번에 받을 행 수 — 발표 하나가 약 850~1,000행이다(시간당 12개 요소 × 최대 4일) */
const ROWS_PER_PAGE = 1500;
/** 입력 화면을 오래 막지 않게 짧게 두고, 한 번만 다시 시도한다 */
const TIMEOUT_MS = 3_000;
const MAX_ATTEMPTS = 2;
const RESULT_OK = '00';
const RESULT_NO_DATA = '03';
const RESULT_QUOTA_EXCEEDED = '22';
export interface KmaPage {
items: PublicDataItem[];
totalCount: number;
/** 결과 코드 03(NODATA_ERROR) — 발표 직후라 아직 자료가 없을 때 온다 */
noData: boolean;
}
/**
* 응답 → 항목. `{ response: { header: { resultCode }, body: { items: { item }, totalCount } } }` 이고 정상 코드는 '00'.
* 03 은 오류가 아니라 '아직 자료 없음'으로 돌려준다(앞 발표로 다시 받는다).
*/
export function parseKmaPage(body: string, operation: string): KmaPage {
const response = asRecord(
asRecord(parseJsonOrThrow(body, operation))?.response,
);
const header = asRecord(response?.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
if (code === RESULT_NO_DATA)
return { items: [], totalCount: 0, noData: true };
if (code !== RESULT_OK) {
if (code === RESULT_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(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,
noData: false,
};
}
/** 날씨 서비스가 쓰는 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface KmaForecastApi {
forecast(issue: KmaIssue, grid: KmaGrid): Promise<KmaPage>;
}
export class KmaForecastClient
extends PublicDataClient<KmaPage>
implements KmaForecastApi
{
constructor(options: PublicDataClientOptions) {
super(
options,
KMA_FORECAST_BASE_URL,
{ dataType: 'JSON' },
{ timeoutMs: TIMEOUT_MS, maxAttempts: MAX_ATTEMPTS },
);
}
forecast(issue: KmaIssue, grid: KmaGrid): Promise<KmaPage> {
return this.get(KMA_FORECAST_OPERATION, {
pageNo: 1,
numOfRows: ROWS_PER_PAGE,
...issueParams(issue),
nx: grid.nx,
ny: grid.ny,
});
}
protected parse(body: string, operation: string): KmaPage {
return parseKmaPage(body, operation);
}
}
@@ -0,0 +1,242 @@
import {
issuedAtIso,
issueParams,
type KmaIssue,
lastForecastDate,
latestIssue,
parseKmaItems,
previousIssue,
summarizeWindow,
} from './kma-forecast';
const kst = (iso: string) => new Date(`${iso}+09:00`);
const at = (hour: number) => hour * 60;
/** 그날 hours 시각마다 강수확률·기온·하늘·강수 형태 항목을 만든다 */
function items(
date: string,
hours: number[],
value: (hour: number) => {
pop: number;
tmp: number;
sky: number;
pty: number;
},
) {
const out: Record<string, string>[] = [];
for (const hour of hours) {
const v = value(hour);
const base = {
fcstDate: date.replace(/-/g, ''),
fcstTime: `${String(hour).padStart(2, '0')}00`,
};
out.push({ ...base, category: 'POP', fcstValue: String(v.pop) });
out.push({ ...base, category: 'TMP', fcstValue: String(v.tmp) });
out.push({ ...base, category: 'SKY', fcstValue: String(v.sky) });
out.push({ ...base, category: 'PTY', fcstValue: String(v.pty) });
}
return out;
}
const range = (from: number, to: number, step = 1) =>
Array.from(
{ length: Math.floor((to - from) / step) + 1 },
(_, i) => from + i * step,
);
describe('latestIssue · previousIssue', () => {
it('발표 10분 뒤부터 그 발표를 쓴다', () => {
expect(latestIssue(kst('2026-11-08T11:09:00'))).toEqual({
date: '2026-11-08',
hour: 8,
});
expect(latestIssue(kst('2026-11-08T11:10:00'))).toEqual({
date: '2026-11-08',
hour: 11,
});
});
it('02:10 전에는 전날 23시 발표다', () => {
expect(latestIssue(kst('2026-11-08T02:09:00'))).toEqual({
date: '2026-11-07',
hour: 23,
});
expect(latestIssue(kst('2026-11-08T02:10:00'))).toEqual({
date: '2026-11-08',
hour: 2,
});
});
it('앞 발표 — 02시 앞은 전날 23시', () => {
expect(previousIssue({ date: '2026-11-08', hour: 11 })).toEqual({
date: '2026-11-08',
hour: 8,
});
expect(previousIssue({ date: '2026-11-01', hour: 2 })).toEqual({
date: '2026-10-31',
hour: 23,
});
});
});
describe('issueParams · lastForecastDate · issuedAtIso', () => {
it('요청 값은 YYYYMMDD · HH00 이다', () => {
expect(issueParams({ date: '2026-11-08', hour: 2 })).toEqual({
base_date: '20261108',
base_time: '0200',
});
});
it('02~14시 발표는 글피(3일 뒤), 17~23시 발표는 그글피(4일 뒤)까지 나온다', () => {
expect(lastForecastDate({ date: '2026-11-08', hour: 14 })).toBe(
'2026-11-11',
);
expect(lastForecastDate({ date: '2026-11-08', hour: 17 })).toBe(
'2026-11-12',
);
});
it('발표 시각을 UTC 로 낸다', () => {
expect(issuedAtIso({ date: '2026-11-08', hour: 11 })).toBe(
'2026-11-08T02:00:00.000Z',
);
expect(issuedAtIso({ date: '2026-11-08', hour: 2 })).toBe(
'2026-11-07T17:00:00.000Z',
);
});
});
describe('parseKmaItems', () => {
it('쓰는 요소만 시각별로 모으고 결측값·형식이 다른 값은 버린다', () => {
const forecast = parseKmaItems([
{
category: 'TMP',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '14',
},
{
category: 'POP',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '60',
},
{
category: 'REH',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '80',
},
{
category: 'POP',
fcstDate: '20261109',
fcstTime: '1400',
fcstValue: '30',
},
{
category: 'TMP',
fcstDate: '20261109',
fcstTime: '1400',
fcstValue: '-999',
},
{
category: 'SKY',
fcstDate: '2026119',
fcstTime: '1400',
fcstValue: '4',
},
]);
expect(forecast).toEqual({
'2026-11-09': [
{ minute: at(14), pop: 30, tmp: null, sky: null, pty: null },
{ minute: at(15), pop: 60, tmp: 14, sky: null, pty: null },
],
});
});
});
describe('summarizeWindow', () => {
const issue: KmaIssue = { date: '2026-11-08', hour: 11 };
it('시간 범위 안의 가장 높은 강수확률과 그때의 강수 형태, 기온 범위, 가장 잦은 하늘을 낸다', () => {
const forecast = parseKmaItems([
...items('2026-11-09', range(0, 23), (h) => ({
pop: h === 17 ? 70 : h === 21 ? 90 : 20,
tmp: 10 + (h % 6),
sky: h < 16 ? 3 : 4,
pty: h === 17 ? 1 : h === 21 ? 3 : 0,
})),
]);
expect(
summarizeWindow(forecast, issue, '2026-11-09', at(14), at(20)),
).toEqual({
date: '2026-11-09',
// 21시 90% 는 범위 밖이다
precipitationProbability: 70,
precipitationType: 'rain',
// 14·15시 구름많음, 16~19시 흐림
sky: 'cloudy',
temperatureMin: 10,
temperatureMax: 15,
issuedAt: '2026-11-08T02:00:00.000Z',
});
});
it('3시간 간격 예보는 한 시각이 3시간을 맡는다(12시 값이 14시를 덮는다)', () => {
const forecast = parseKmaItems([
...items('2026-11-11', range(0, 21, 3), (h) => ({
pop: h === 12 ? 60 : 10,
tmp: 12,
sky: 1,
pty: 0,
})),
...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })),
]);
const summary = summarizeWindow(
forecast,
issue,
'2026-11-11',
at(14),
at(20),
);
expect(summary?.precipitationProbability).toBe(60);
expect(summary?.sky).toBe('clear');
});
it('예보가 시간 범위 끝까지 닿지 않으면 null', () => {
const forecast = parseKmaItems([
// 글피 다음 날은 0시 값만 온다
...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })),
...items('2026-11-10', range(0, 18), () => ({
pop: 0,
tmp: 8,
sky: 1,
pty: 0,
})),
]);
expect(
summarizeWindow(forecast, issue, '2026-11-12', at(14), at(20)),
).toBeNull();
expect(
summarizeWindow(forecast, issue, '2026-11-10', at(14), at(20)),
).toBeNull();
expect(
summarizeWindow(forecast, issue, '2026-11-13', at(14), at(20)),
).toBeNull();
});
it('발표 당일은 이미 지난 시각의 예보가 없어도 된다', () => {
const forecast = parseKmaItems([
...items('2026-11-08', range(12, 23), () => ({
pop: 30,
tmp: 15,
sky: 1,
pty: 0,
})),
]);
expect(
summarizeWindow(forecast, issue, '2026-11-08', at(11), at(17))
?.precipitationProbability,
).toBe(30);
});
});
+252
View File
@@ -0,0 +1,252 @@
/**
* 기상청 단기예보(getVilageFcst) 발표 시각 고르기·응답 해석·시간 범위 요약 (순수 함수).
* 매뉴얼 검토: docs/research/data-openapi-review/store_and_weather.md §4·§5. 설계: docs/design/ai-course.md §3.3
*/
import type { PublicDataItem } from '@/modules/place/sync/public-data.client';
import { addDaysIso } from '@/shared/utils/iso-date';
import { kstClock } from '@/shared/utils/kst';
/** 단기예보 발표 시각(시, 한국 시간) — 하루 8번 */
export const KMA_ISSUE_HOURS: readonly number[] = [2, 5, 8, 11, 14, 17, 20, 23];
/** 발표 뒤 이만큼(분) 지나야 받을 수 있다 */
const AVAILABLE_AFTER_MINUTES = 10;
/** 이 시각 이후 발표는 그글피(4일 뒤)까지, 앞의 발표는 글피(3일 뒤)까지 나온다 */
const LONG_RANGE_FROM_HOUR = 17;
const SHORT_RANGE_DAYS = 3;
const LONG_RANGE_DAYS = 4;
/** 기상청 결측값 — 절댓값이 이보다 크면 버린다(매뉴얼: +900 이상, -900 이하) */
const MISSING_VALUE_ABS = 900;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_DAY = 1440;
const KST_OFFSET_HOURS = 9;
/** 단기예보 발표 하나 — 한국 날짜 'YYYY-MM-DD' 와 시 */
export interface KmaIssue {
date: string;
hour: number;
}
/** 지금 받을 수 있는 가장 최근 발표 */
export function latestIssue(now: Date): KmaIssue {
const { date, minutes } = kstClock(now);
for (let i = KMA_ISSUE_HOURS.length - 1; i >= 0; i--) {
const hour = KMA_ISSUE_HOURS[i];
if (minutes >= hour * MINUTES_PER_HOUR + AVAILABLE_AFTER_MINUTES) {
return { date, hour };
}
}
return previousIssue({ date, hour: KMA_ISSUE_HOURS[0] });
}
/** 바로 앞 발표 — 최근 발표 자료가 아직 없을 때(발표가 늦어질 때) 쓴다 */
export function previousIssue(issue: KmaIssue): KmaIssue {
const index = KMA_ISSUE_HOURS.indexOf(issue.hour);
if (index > 0) return { date: issue.date, hour: KMA_ISSUE_HOURS[index - 1] };
return {
date: addDaysIso(issue.date, -1) ?? issue.date,
hour: KMA_ISSUE_HOURS[KMA_ISSUE_HOURS.length - 1],
};
}
/** API 요청 값 base_date('YYYYMMDD')·base_time('HH00') */
export function issueParams(issue: KmaIssue): {
base_date: string;
base_time: string;
} {
return {
base_date: issue.date.replace(/-/g, ''),
base_time: `${String(issue.hour).padStart(2, '0')}00`,
};
}
/** 그 발표가 예보를 내는 마지막 날 — 02~14시 발표는 글피, 17~23시 발표는 그글피 */
export function lastForecastDate(issue: KmaIssue): string {
const days =
issue.hour >= LONG_RANGE_FROM_HOUR ? LONG_RANGE_DAYS : SHORT_RANGE_DAYS;
return addDaysIso(issue.date, days) ?? issue.date;
}
/** 발표 시각(ISO 8601 UTC) — API 규약에 맞춰 UTC 로 낸다 */
export function issuedAtIso(issue: KmaIssue): string {
const [y, m, d] = issue.date.split('-').map(Number);
return new Date(
Date.UTC(y, m - 1, d, issue.hour - KST_OFFSET_HOURS),
).toISOString();
}
/** 예보 시각 하나의 값. 받지 못한 요소는 null */
export interface ForecastPoint {
/** 그날 00:00 부터의 분 */
minute: number;
/** 강수확률(%) */
pop: number | null;
/** 기온(℃) */
tmp: number | null;
/** 하늘 상태 코드 — 1 맑음 · 3 구름많음 · 4 흐림 */
sky: number | null;
/** 강수 형태 코드 — 0 없음 · 1 비 · 2 비/눈 · 3 눈 · 4 소나기 */
pty: number | null;
}
/** 날짜('YYYY-MM-DD')별 예보 시각(시각 순) */
export type KmaForecast = Record<string, ForecastPoint[]>;
const CATEGORY_FIELDS: Readonly<
Record<string, keyof Omit<ForecastPoint, 'minute'>>
> = {
POP: 'pop',
TMP: 'tmp',
SKY: 'sky',
PTY: 'pty',
};
/** 응답 값(문자열·숫자로 온다)을 글자로. 다른 형식이면 빈 문자열 */
function text(value: unknown): string {
return typeof value === 'string' || typeof value === 'number'
? String(value)
: '';
}
/**
* 응답 항목({category, fcstDate 'YYYYMMDD', fcstTime 'HHMM', fcstValue}) → 날짜별 예보 시각.
* 쓰는 요소(강수확률·기온·하늘·강수 형태)만 남기고, 결측값·형식이 다른 값은 버린다.
*/
export function parseKmaItems(items: PublicDataItem[]): KmaForecast {
const byDate = new Map<string, Map<number, ForecastPoint>>();
for (const item of items) {
const field = CATEGORY_FIELDS[text(item.category)];
const date = /^(\d{4})(\d{2})(\d{2})$/.exec(text(item.fcstDate));
const time = /^(\d{2})(\d{2})$/.exec(text(item.fcstTime));
const value = Number(text(item.fcstValue) || Number.NaN);
if (!field || !date || !time || !Number.isFinite(value)) continue;
if (Math.abs(value) >= MISSING_VALUE_ABS) continue;
const day = `${date[1]}-${date[2]}-${date[3]}`;
const minute = Number(time[1]) * MINUTES_PER_HOUR + Number(time[2]);
const points = byDate.get(day) ?? new Map<number, ForecastPoint>();
const point = points.get(minute) ?? {
minute,
pop: null,
tmp: null,
sky: null,
pty: null,
};
point[field] = value;
points.set(minute, point);
byDate.set(day, points);
}
const out: KmaForecast = {};
for (const [day, points] of byDate) {
out[day] = [...points.values()].sort((a, b) => a.minute - b.minute);
}
return out;
}
export type PrecipitationType =
| 'none'
| 'rain'
| 'rain_snow'
| 'snow'
| 'shower';
export type SkyState = 'clear' | 'partly_cloudy' | 'cloudy';
const PRECIPITATION_TYPES: Readonly<Record<number, PrecipitationType>> = {
0: 'none',
1: 'rain',
2: 'rain_snow',
3: 'snow',
4: 'shower',
};
const SKY_STATES: Readonly<Record<number, SkyState>> = {
1: 'clear',
3: 'partly_cloudy',
4: 'cloudy',
};
/** 코스 시간 범위의 예보 요약 — 입력 화면("토요일 오후 비 60%")과 결과의 날씨에 쓴다(설계 §3.7) */
export interface WindowForecast {
date: string;
/** 시간 범위 중 가장 높은 강수확률(%) — 40% 이상이면 비 조건(prefersIndoor) */
precipitationProbability: number;
/** 강수확률이 가장 높은 때의 강수 형태 */
precipitationType: PrecipitationType;
/** 시간 범위에서 가장 잦은 하늘 상태(같으면 더 흐린 쪽) */
sky: SkyState | null;
temperatureMin: number | null;
temperatureMax: number | null;
/** 발표 시각(ISO 8601 UTC) — 화면에 "11시 발표"로 보인다 */
issuedAt: string;
}
/** 예보 시각마다 그 값이 맡는 구간 [minute, 다음 예보 시각) — 마지막 시각은 다음 날 0시나 앞 간격만큼 */
function intervals(
points: ForecastPoint[],
nextDayFirst: ForecastPoint | undefined,
): { point: ForecastPoint; from: number; to: number }[] {
return points.map((point, i) => {
const next = points[i + 1]?.minute;
let to: number;
if (next !== undefined) {
to = next;
} else if (nextDayFirst?.minute === 0) {
to = MINUTES_PER_DAY;
} else {
const gap =
i > 0 ? point.minute - points[i - 1].minute : MINUTES_PER_HOUR;
to = Math.min(point.minute + gap, MINUTES_PER_DAY);
}
return { point, from: point.minute, to };
});
}
/**
* 그날 [start, end) 의 예보 요약. 예보가 시간 범위 끝까지 닿지 않으면 null(입력 화면에 '예보 없음').
* 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다. 발표 당일은 이미 지난 시각의 예보가 없어도 된다.
*/
export function summarizeWindow(
forecast: KmaForecast,
issue: KmaIssue,
date: string,
start: number,
end: number,
): WindowForecast | null {
const points = forecast[date] ?? [];
if (points.length === 0 || end <= start) return null;
const nextDay = addDaysIso(date, 1);
const spans = intervals(points, nextDay ? forecast[nextDay]?.[0] : undefined);
const covered = spans.filter((s) => s.from < end && start < s.to);
if (covered.length === 0) return null;
if (spans[spans.length - 1].to < end) return null;
if (date !== issue.date && spans[0].from > start) return null;
const withPop = covered.filter((s) => s.point.pop !== null);
if (withPop.length === 0) return null;
const wettest = withPop.reduce((a, b) =>
(b.point.pop ?? 0) > (a.point.pop ?? 0) ? b : a,
);
const temps = covered
.map((s) => s.point.tmp)
.filter((t): t is number => t !== null);
const skyCounts = new Map<number, number>();
for (const s of covered) {
if (s.point.sky !== null) {
skyCounts.set(s.point.sky, (skyCounts.get(s.point.sky) ?? 0) + 1);
}
}
let sky: number | null = null;
for (const [code, count] of skyCounts) {
const best = sky === null ? 0 : (skyCounts.get(sky) ?? 0);
if (count > best || (count === best && sky !== null && code > sky)) {
sky = code;
}
}
return {
date,
precipitationProbability: wettest.point.pop ?? 0,
precipitationType: PRECIPITATION_TYPES[wettest.point.pty ?? 0] ?? 'none',
sky: sky === null ? null : (SKY_STATES[sky] ?? null),
temperatureMin: temps.length > 0 ? Math.min(...temps) : null,
temperatureMax: temps.length > 0 ? Math.max(...temps) : null,
issuedAt: issuedAtIso(issue),
};
}
@@ -0,0 +1,11 @@
import { toKmaGrid } from './kma-grid';
describe('toKmaGrid', () => {
it('서울시청은 기상청 격자 (60, 127) 이다', () => {
expect(toKmaGrid(37.5665, 126.978)).toEqual({ nx: 60, ny: 127 });
});
it('대구 도심은 (89, 90) 이다(2026-10-29 실제 호출과 같다)', () => {
expect(toKmaGrid(35.87021, 128.59409)).toEqual({ nx: 89, ny: 90 });
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* 위도·경도 → 기상청 단기예보 격자(nx, ny) (순수 함수).
* 기상청이 공개한 람베르트 정각원추도법(LCC) 공식이다. 2026-10-29 격자표 3,801행과 대조해 98.9% 가 정확히 맞고
* 나머지 43행도 1칸 차이였다(docs/research/data-openapi-review/store_and_weather.md §6).
*/
/** 지구 반지름(km)과 격자 간격(km) */
const EARTH_RADIUS_KM = 6371.00877;
const GRID_SPACING_KM = 5.0;
/** 표준 위도 1·2, 기준점 경도·위도 */
const STANDARD_LAT_1 = 30.0;
const STANDARD_LAT_2 = 60.0;
const ORIGIN_LNG = 126.0;
const ORIGIN_LAT = 38.0;
/** 기준점의 격자 좌표 */
const ORIGIN_X = 43;
const ORIGIN_Y = 136;
const DEG = Math.PI / 180;
const QUARTER_PI = Math.PI / 4;
export interface KmaGrid {
nx: number;
ny: number;
}
export function toKmaGrid(lat: number, lng: number): KmaGrid {
const re = EARTH_RADIUS_KM / GRID_SPACING_KM;
const s1 = STANDARD_LAT_1 * DEG;
const s2 = STANDARD_LAT_2 * DEG;
const sn =
Math.log(Math.cos(s1) / Math.cos(s2)) /
Math.log(Math.tan(QUARTER_PI + s2 / 2) / Math.tan(QUARTER_PI + s1 / 2));
const sf = (Math.tan(QUARTER_PI + s1 / 2) ** sn * Math.cos(s1)) / sn;
const ro = (re * sf) / Math.tan(QUARTER_PI + (ORIGIN_LAT * DEG) / 2) ** sn;
const ra = (re * sf) / Math.tan(QUARTER_PI + (lat * DEG) / 2) ** sn;
let theta = lng * DEG - ORIGIN_LNG * DEG;
if (theta > Math.PI) theta -= 2 * Math.PI;
if (theta < -Math.PI) theta += 2 * Math.PI;
theta *= sn;
return {
nx: Math.floor(ra * Math.sin(theta) + ORIGIN_X + 0.5),
ny: Math.floor(ro - ra * Math.cos(theta) + ORIGIN_Y + 0.5),
};
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import {
createKmaApi,
KMA_API_FACTORY,
WeatherService,
} from './weather.service';
/**
* 날씨(기상청 단기예보). AI 코스 조건 해석·생성(설계 §3.3)이 WeatherService 를 쓴다.
* 설정(ConfigModule)과 캐시(CacheModule)는 앱 전역 모듈을 쓴다.
*/
@Module({
providers: [
WeatherService,
{ provide: KMA_API_FACTORY, useValue: createKmaApi },
],
exports: [WeatherService],
})
export class WeatherModule {}
@@ -0,0 +1,166 @@
import type { ConfigService } from '@nestjs/config';
import type { Cache } from 'cache-manager';
import type { CallBudget } from '@/modules/place/sync/call-budget';
import { PublicDataApiError } from '@/modules/place/sync/public-data.client';
import type { KmaForecastApi, KmaPage } from './kma-forecast.client';
import type { KmaIssue } from './kma-forecast';
import type { KmaGrid } from './kma-grid';
import { WeatherService } from './weather.service';
const DOWNTOWN = { lat: 35.87021, lng: 128.59409 };
/** 2026-11-08(일) 11:30 KST — 최근 발표는 11시, 예보는 글피(11-11)까지 */
const NOW = new Date('2026-11-08T11:30:00+09:00');
const at = (hour: number) => hour * 60;
/** 11-08 ~ 11-11 매시, 11-12 0시의 강수확률이 pop 인 응답 */
function page(pop: number): KmaPage {
const items: Record<string, string>[] = [];
const dates = ['20261108', '20261109', '20261110', '20261111', '20261112'];
for (const date of dates) {
for (let h = 0; h < 24; h++) {
if (date === '20261112' && h > 0) break;
const base = {
fcstDate: date,
fcstTime: `${String(h).padStart(2, '0')}00`,
};
items.push({ ...base, category: 'POP', fcstValue: String(pop) });
items.push({ ...base, category: 'TMP', fcstValue: '12' });
items.push({ ...base, category: 'SKY', fcstValue: '4' });
items.push({ ...base, category: 'PTY', fcstValue: pop > 0 ? '1' : '0' });
}
}
return { items, totalCount: items.length, noData: false };
}
const NO_DATA: KmaPage = { items: [], totalCount: 0, noData: true };
function memoryCache(): Cache {
const store = new Map<string, unknown>();
return {
get: (key: string) => Promise.resolve(store.get(key)),
set: (key: string, value: unknown) => {
store.set(key, value);
return Promise.resolve(value);
},
} as unknown as Cache;
}
function setup(
respond: (issue: KmaIssue, grid: KmaGrid) => Promise<KmaPage>,
key = 'test-key',
) {
const calls: { issue: KmaIssue; grid: KmaGrid }[] = [];
const api: KmaForecastApi = {
forecast: (issue, grid) => {
calls.push({ issue, grid });
return respond(issue, grid);
},
};
const budgets: CallBudget[] = [];
const config = { get: () => key } as unknown as ConfigService;
const service = new WeatherService(config, memoryCache(), (_key, budget) => {
budgets.push(budget);
return api;
});
return { service, calls, budgets };
}
describe('WeatherService.forecastWindow', () => {
it('최근 발표를 격자로 불러 시간 범위를 요약하고, 같은 격자·발표는 다시 부르지 않는다', async () => {
const { service, calls } = setup(() => Promise.resolve(page(60)));
const first = await service.forecastWindow(
DOWNTOWN,
'2026-11-09',
at(14),
at(20),
NOW,
);
expect(first).toMatchObject({
date: '2026-11-09',
precipitationProbability: 60,
precipitationType: 'rain',
sky: 'cloudy',
issuedAt: '2026-11-08T02:00:00.000Z',
});
await service.forecastWindow(DOWNTOWN, '2026-11-10', at(10), at(16), NOW);
expect(calls).toEqual([
{ issue: { date: '2026-11-08', hour: 11 }, grid: { nx: 89, ny: 90 } },
]);
});
it('최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다', async () => {
const { service, calls } = setup((issue) =>
Promise.resolve(issue.hour === 11 ? NO_DATA : page(10)),
);
const summary = await service.forecastWindow(
DOWNTOWN,
'2026-11-09',
at(14),
at(20),
NOW,
);
expect(summary?.issuedAt).toBe('2026-11-07T23:00:00.000Z');
expect(calls.map((c) => c.issue.hour)).toEqual([11, 8]);
});
it('기상청 장애면 null 이고, 잠깐 동안은 다시 부르지 않는다', async () => {
const { service, calls } = setup(() =>
Promise.reject(new PublicDataApiError('getVilageFcst: HTTP 502')),
);
for (let i = 0; i < 2; i++) {
expect(
await service.forecastWindow(
DOWNTOWN,
'2026-11-09',
at(14),
at(20),
NOW,
),
).toBeNull();
}
expect(calls).toHaveLength(1);
});
it('지난 날짜·예보가 닿지 않는 날짜·한국 밖은 부르지 않고 null', async () => {
const { service, calls } = setup(() => Promise.resolve(page(0)));
const window = [at(14), at(20), NOW] as const;
expect(
await service.forecastWindow(DOWNTOWN, '2026-11-07', ...window),
).toBeNull();
// 11시 발표는 글피(11-11)까지다
expect(
await service.forecastWindow(DOWNTOWN, '2026-11-12', ...window),
).toBeNull();
expect(
await service.forecastWindow({ lat: 0, lng: 0 }, '2026-11-09', ...window),
).toBeNull();
expect(calls).toHaveLength(0);
});
it('키가 없으면 부르지 않고 null', async () => {
const { service, calls, budgets } = setup(
() => Promise.resolve(page(0)),
'',
);
expect(
await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW),
).toBeNull();
expect(calls).toHaveLength(0);
expect(budgets).toHaveLength(0);
});
it('한국 날짜가 바뀌면 하루 호출 수를 새로 센다', async () => {
const { service, budgets } = setup(() => Promise.resolve(page(0)));
await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW);
await service.forecastWindow(
DOWNTOWN,
'2026-11-10',
at(14),
at(20),
new Date('2026-11-09T09:00:00+09:00'),
);
expect(budgets).toHaveLength(2);
});
});
@@ -0,0 +1,167 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Cache } from 'cache-manager';
import { CallBudget } from '@/modules/place/sync/call-budget';
import { isInKorea, type LatLng } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { KmaForecastClient, type KmaForecastApi } from './kma-forecast.client';
import {
type KmaForecast,
type KmaIssue,
lastForecastDate,
latestIssue,
parseKmaItems,
previousIssue,
summarizeWindow,
type WindowForecast,
} from './kma-forecast';
import { type KmaGrid, toKmaGrid } from './kma-grid';
/** 기상청 클라이언트를 만든다 — 테스트에서는 가짜로 바꿔 끼운다 */
export const KMA_API_FACTORY = Symbol('KMA_API_FACTORY');
export type KmaApiFactory = (
serviceKey: string,
budget: CallBudget,
) => KmaForecastApi;
export const createKmaApi: KmaApiFactory = (serviceKey, budget) =>
new KmaForecastClient({ serviceKey, budget });
const SERVICE_KEY_NAME = 'PUBLIC_DATA_SERVICE_KEY';
/** 격자·발표 시각별 예보를 이만큼(ms) 다시 부르지 않는다(설계 §3.3) */
const FORECAST_TTL_MS = 60 * 60 * 1000;
/** 실패·자료 없음은 짧게 기억한다 — 장애 때 요청마다 제한 시간만큼 기다리지 않게 */
const FAILURE_TTL_MS = 2 * 60 * 1000;
/**
* 하루 호출 상한(한국 시간 자정에 다시 센다) — 캐시가 있어 실제로는 하루 수십 번이다.
* 공공데이터포털 개발 계정 한도보다 낮게 두어 키 오류·버그로 한도를 다 쓰지 않게 한다.
*/
const DAILY_CALL_LIMIT = 1_000;
type CachedForecast =
| { kind: 'ok'; forecast: KmaForecast }
| { kind: 'no_data' }
| { kind: 'failed' };
function cacheKey(grid: KmaGrid, issue: KmaIssue): string {
return `weather:kma:${grid.nx},${grid.ny}:${issue.date}T${issue.hour}`;
}
/**
* 코스 날짜·지역의 단기예보(설계 §3.3). 입력 화면을 막지 않도록 **실패하면 던지지 않고 null** 을 돌려준다
* (키 없음 · 예보가 없는 날짜 · 한국 밖 · 기상청 장애). null 이면 화면에 '예보 없음'을 보이고 비 조건은 쓰지 않는다.
*/
@Injectable()
export class WeatherService {
private readonly logger = new Logger(WeatherService.name);
private daily: { date: string; api: KmaForecastApi } | null = null;
private readonly inflight = new Map<string, Promise<CachedForecast>>();
private warnedMissingKey = false;
constructor(
private readonly config: ConfigService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(KMA_API_FACTORY) private readonly createApi: KmaApiFactory,
) {}
/**
* 그날 [start, end)(00:00 부터 분) 의 예보 요약. 지난 날짜, 예보가 닿지 않는 날짜(4일 뒤 이후)는 null.
* @param now 지금 시각 — 발표 시각을 고른다(테스트에서 고정)
*/
async forecastWindow(
point: LatLng,
date: string,
start: number,
end: number,
now: Date = new Date(),
): Promise<WindowForecast | null> {
if (date < kstDateString(now)) return null;
if (!isInKorea(point.lat, point.lng)) return null;
const latest = latestIssue(now);
if (date > lastForecastDate(latest)) return null;
const grid = toKmaGrid(point.lat, point.lng);
// 최근 발표 자료가 아직 없으면(발표가 늦어질 때) 앞 발표를 쓴다
for (const issue of [latest, previousIssue(latest)]) {
const cached = await this.load(grid, issue, now);
if (cached.kind === 'failed') return null;
if (cached.kind === 'ok') {
return summarizeWindow(cached.forecast, issue, date, start, end);
}
}
return null;
}
private async load(
grid: KmaGrid,
issue: KmaIssue,
now: Date,
): Promise<CachedForecast> {
const key = cacheKey(grid, issue);
const cached = await this.cache.get<CachedForecast>(key);
if (cached) return cached;
// 같은 격자를 동시에 물으면 한 번만 부른다
let pending = this.inflight.get(key);
if (!pending) {
pending = this.fetch(grid, issue, now).finally(() =>
this.inflight.delete(key),
);
this.inflight.set(key, pending);
}
const result = await pending;
await this.cache.set(
key,
result,
result.kind === 'ok' ? FORECAST_TTL_MS : FAILURE_TTL_MS,
);
return result;
}
private async fetch(
grid: KmaGrid,
issue: KmaIssue,
now: Date,
): Promise<CachedForecast> {
const api = this.api(now);
if (!api) return { kind: 'failed' };
try {
const page = await api.forecast(issue, grid);
if (page.noData) return { kind: 'no_data' };
if (page.totalCount > page.items.length) {
this.logger.warn(
`기상청 단기예보가 잘려서 왔다(격자 ${grid.nx},${grid.ny} · ${page.items.length}/${page.totalCount}행)`,
);
}
return { kind: 'ok', forecast: parseKmaItems(page.items) };
} catch (error) {
// 클라이언트가 오류 메시지의 키를 가린다. 사용자 위치 대신 5km 격자만 남긴다
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(
`기상청 단기예보를 받지 못했다(격자 ${grid.nx},${grid.ny} · ${issue.date} ${issue.hour}시 발표): ${reason}`,
);
return { kind: 'failed' };
}
}
/** 오늘 쓸 클라이언트 — 한국 날짜가 바뀌면 하루 호출 수를 다시 센다. 키가 없으면 null */
private api(now: Date): KmaForecastApi | null {
const today = kstDateString(now);
if (this.daily?.date === today) return this.daily.api;
const key = this.config.get<string>(SERVICE_KEY_NAME)?.trim();
if (!key || /change[-_]?me/i.test(key)) {
if (!this.warnedMissingKey) {
// 키 값은 어떤 경우에도 남기지 않는다
this.logger.warn(
`${SERVICE_KEY_NAME} 가 없어 날씨 예보를 쓰지 않는다(입력 화면은 '예보 없음')`,
);
this.warnedMissingKey = true;
}
return null;
}
const budget = new CallBudget(DAILY_CALL_LIMIT);
this.daily = { date: today, api: this.createApi(key, budget) };
return this.daily.api;
}
}
@@ -26,6 +26,11 @@ const POSITIVE_INT_KEYS = [
'THROTTLE_LIMIT',
// 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
'TOUR_API_DAILY_LIMIT',
// AI 코스 생성 — 하루 횟수(비로그인·로그인), 하루 비용 상한(정수 달러), AI 호출 제한 시간(ms). 없으면 코드 기본값
'AI_DAILY_LIMIT_GUEST',
'AI_DAILY_LIMIT_MEMBER',
'AI_DAILY_BUDGET_USD',
'AI_TIMEOUT_MS',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
+56 -1
View File
@@ -1,4 +1,9 @@
import { isInKorea } from './geo';
import {
distanceMeters,
isInKorea,
isInSido,
sidoCodeFromAddress,
} from './geo';
describe('isInKorea', () => {
it('대구 좌표는 한국 안이다', () => {
@@ -14,3 +19,53 @@ describe('isInKorea', () => {
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);
});
});
describe('distanceMeters', () => {
it('위도 1도는 약 111.2km 다', () => {
expect(
distanceMeters({ lat: 35, lng: 128 }, { lat: 36, lng: 128 }),
).toBeCloseTo(111_195, -1);
});
it('같은 좌표는 0 이고, 순서를 바꿔도 같다', () => {
const downtown = { lat: 35.87021, lng: 128.59409 };
const suseongmot = { lat: 35.8276, lng: 128.6177 };
expect(distanceMeters(downtown, downtown)).toBe(0);
expect(distanceMeters(downtown, suseongmot)).toBeCloseTo(
distanceMeters(suseongmot, downtown),
6,
);
// 대구 도심 → 수성못 약 5.2km
expect(Math.round(distanceMeters(downtown, suseongmot) / 100)).toBe(52);
});
});

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