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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:30:08 +09:00
ttipo 295d74f4d3 feat(backend): 축제·공연·행사 동기화와 조회 API 추가
홈 '이번 주말 축제·행사'와 장소 상세 '근처 축제·행사'에 쓸 데이터를 채운다.

- place:sync -- event: 관광공사 searchFestival2 를 오늘~90일 기간으로 받아
  festival_event 에 upsert(서버는 기간이 겹치는 행사를 준다). 지역 조건을 보내고
  응답에서도 다른 지역 행사는 거른다. 상세(소개·요금·공연 시간·장소)는 새로 생기거나
  수정된 행사만 받는다. 기간 안에서 목록에 없는 행사는 숨긴다
- GET /api/events: 기간(기본 오늘~30일, 최대 92일)·근처(좌표+반경)·분류·커서.
  60일 넘는 상시 프로그램과 숨김 행사는 뺀다. 항목에 오늘 진행 중 여부
- GET /api/events/{id}: 소개·공연 시간·요금·장소·홈페이지·출처
- 마이그레이션: festival_event 에 분류 코드·지역 코드·장소명·전화·홈페이지·
  작은 사진·원본 수정일·상세 수신 시각 추가

e2e(test/festival.e2e-spec.ts)는 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
2026-11-03 실측: 앞으로 90일 관광공사 행사는 전국 106건, 대구 1건, 경북 7건.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:45:28 +09:00
373 changed files with 40505 additions and 1189 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
@@ -57,3 +57,7 @@ Thumbs.db
.cache/
.eslintcache
.npm/
# Python 캐시 (tools/ 점검 스크립트)
__pycache__/
*.pyc
+24
View File
@@ -0,0 +1,24 @@
# 이미지 빌드 컨텍스트에서 뺄 파일 — 빌드를 빠르게 하고, 비밀값·호스트 산출물이 이미지에 들어가지 않게 한다.
# (없으면 Dockerfile 의 `COPY . .` 가 아래 파일을 전부 이미지에 넣는다)
# 의존성 — 이미지 안에서 npm ci 로 설치한다.
# 호스트(Windows) node_modules 가 들어오면 Linux 용 모듈을 덮어써 네이티브 모듈(swc 등)이 깨진다.
node_modules
# 빌드·테스트 산출물 — 이미지 안에서 npm run build 로 다시 만든다
dist
coverage
*.tsbuildinfo
# 환경변수 — 실행할 때 compose 가 env_file/environment 로 넣는다. 이미지에 넣으면 키가 레이어에 남는다
.env
.env.*
# 업로드 파일(로컬 디스크 저장소) — 사용자 파일은 이미지가 아니라 볼륨에 둔다
uploads
# 기타
*.log
.git
.vscode
.idea
+19
View File
@@ -13,3 +13,22 @@
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# 공공데이터포털 일반 인증키 — 마이페이지의 "Decoding" 키를 넣는다.
# 한 키로 활용신청이 승인된 모든 API(관광공사 국문·영문·반려동물, 기상청 단기예보, 상가정보)를 호출한다.
# 개발 계정은 관광공사 API 하루 1,000건 제한이 있다.
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
# 운영 계정 승인 후 승인된 한도로 올린다.
TOUR_API_DAILY_LIMIT=
# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키).
# kopis.or.kr → Open API 에서 신청하면 이메일로 온다.
KOPIS_SERVICE_KEY=change-me-kopis-service-key
# 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다.
# 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
# Claude API 키 — AI 코스 생성. 사용량만큼 과금된다.
ANTHROPIC_API_KEY=change-me-anthropic-api-key
+16
View File
@@ -13,3 +13,19 @@
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# 공공데이터포털 일반 인증키(Decoding) — 운영 전 관광공사 API 는 활용사례를 등록해 운영 계정으로 트래픽을 늘린다.
PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key
# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정).
# 운영 계정 승인 후 승인된 한도로 올린다.
TOUR_API_DAILY_LIMIT=
# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키).
# kopis.or.kr → Open API 에서 신청하면 이메일로 온다.
KOPIS_SERVICE_KEY=change-me-kopis-service-key
# 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지.
KAKAO_REST_API_KEY=change-me-kakao-rest-api-key
# Claude API 키 — 운영용 키를 따로 발급하고 사용량 한도를 걸어 둔다.
ANTHROPIC_API_KEY=change-me-anthropic-api-key
+1315 -1081
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -25,7 +25,10 @@
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create",
"migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert",
"migration:show": "npm run typeorm -- migration:show"
"migration:show": "npm run typeorm -- migration:show",
"place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts",
"place:sync:prod": "node dist/commands/place-sync.js",
"weather:check": "ts-node -r tsconfig-paths/register src/commands/weather-check.ts"
},
"dependencies": {
"@nestjs/cache-manager": "^3.0.1",
@@ -41,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",
@@ -79,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": [
+4
View File
@@ -7,7 +7,9 @@ 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';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -58,6 +60,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
}),
}),
HealthModule,
PlaceModule,
CourseGenerationModule,
UploadModule,
UserModule,
],
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaceSyncModule } from '@/modules/place/sync/place-sync.module';
import { buildDatabaseOptions } from '@/shared/config/database.config';
import { validateEnv } from '@/shared/config/env.validation';
/**
* 장소 동기화 CLI 전용 루트 모듈 — HTTP·Rate Limit·캐시 없이 설정·DB·동기화만 올린다.
* 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
*/
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
validate: validateEnv,
}),
TypeOrmModule.forRootAsync({
useFactory: () => ({
...buildDatabaseOptions(),
autoLoadEntities: true,
// 수천 건 upsert 의 SQL 로그는 진행 상황을 가린다
logging: false,
}),
}),
PlaceSyncModule,
],
})
export class PlaceSyncCliModule {}
+169
View File
@@ -0,0 +1,169 @@
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import {
DEFAULT_EVENT_LOOKAHEAD_DAYS,
DEFAULT_EVENT_REGIONS,
FestivalSync,
} from '@/modules/place/sync/festival.sync';
import { KcisaSync } from '@/modules/place/sync/kcisa.sync';
import { KopisSync } from '@/modules/place/sync/kopis.sync';
import { PlaceDedupe } from '@/modules/place/sync/place-dedupe';
import { PlaceHoursReparse } from '@/modules/place/sync/place-hours-reparse';
import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
import {
DEFAULT_STORE_SIGNGU,
StoreSync,
} from '@/modules/place/sync/store.sync';
import {
DEFAULT_TOUR_REGION,
TourKorSync,
} from '@/modules/place/sync/tour-kor.sync';
import { TourPetSync } from '@/modules/place/sync/tour-pet.sync';
import { winstonConfig } from '@/shared/logger/winston.config';
import { PlaceSyncCliModule } from './place-sync.cli.module';
/**
* 장소 동기화 명령 (설계: docs/design/place-db.md §4).
* 공공데이터·KOPIS 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다.
*
* 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션]
* 운영: node dist/commands/place-sync.js <작업> [옵션] (cron)
*/
const USAGE = `사용법: place:sync <작업> [옵션]
tour-kor 관광공사 국문 장소 적재·갱신 (목록 → 상세). 한도에 닿으면 멈추고, 다시 실행하면 이어서 받는다
--region=27 법정동 시·도 코드 (기본 27 대구)
--skip-details 목록만 갱신
tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출)
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
--regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
kopis KOPIS 공연 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 공연만 상세를, 새 공연장만 좌표를 받는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
kcisa 한국문화정보원 전시 적재(오늘부터 N일 안, 매일). 공연은 KOPIS 와 겹쳐 전시만, 이미지는 받지 않는다
--regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
status 지역별 적재 현황과 오늘 쓴 호출 수
--region=27`;
const JOBS = [
'tour-kor',
'tour-pet',
'store',
'event',
'kopis',
'kcisa',
'dedupe',
'reparse-hours',
'status',
] as const;
type Job = (typeof JOBS)[number];
function parseArgs(argv: string[]): {
job: Job | null;
region: string;
skipDetails: boolean;
signgu: string[];
days: number;
regions: string[];
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
? (first as Job)
: null;
const regionArg = rest.find((a) => a.startsWith('--region='));
const region = regionArg?.split('=')[1]?.trim() || DEFAULT_TOUR_REGION;
const signguArg = rest.find((a) => a.startsWith('--signgu='));
const signgu = signguArg
? signguArg
.slice('--signgu='.length)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_STORE_SIGNGU;
const daysArg = rest.find((a) => a.startsWith('--days='));
const days = daysArg
? Number(daysArg.slice('--days='.length))
: DEFAULT_EVENT_LOOKAHEAD_DAYS;
const regionsArg = rest.find((a) => a.startsWith('--regions='));
const regions = regionsArg
? regionsArg
.slice('--regions='.length)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_EVENT_REGIONS;
return {
job,
region,
skipDetails: rest.includes('--skip-details'),
signgu,
days,
regions,
};
}
async function main(): Promise<void> {
const { job, region, skipDetails, signgu, days, regions } = parseArgs(
process.argv.slice(2),
);
const validSigngu =
signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v));
const validDays = Number.isInteger(days) && days >= 1 && days <= 365;
const validRegions =
regions.length > 0 && regions.every((v) => /^\d{2}$/.test(v));
if (
!job ||
!/^\d{2}$/.test(region) ||
!validSigngu ||
!validDays ||
!validRegions
) {
Logger.error(USAGE, 'PlaceSync');
process.exitCode = 1;
return;
}
const app = await NestFactory.createApplicationContext(PlaceSyncCliModule, {
logger: WinstonModule.createLogger(winstonConfig),
});
try {
if (job === 'tour-kor') {
await app.get(TourKorSync).run({ regionCode: region, skipDetails });
} else if (job === 'tour-pet') {
await app.get(TourPetSync).run();
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'event') {
await app.get(FestivalSync).runRegions({ regionCodes: regions, days });
} else if (job === 'kopis') {
await app.get(KopisSync).runRegions({ regionCodes: regions, days });
} else if (job === 'kcisa') {
await app.get(KcisaSync).runRegions({ regionCodes: regions, days });
} else if (job === 'dedupe') {
await app.get(PlaceDedupe).run();
} else if (job === 'reparse-hours') {
await app.get(PlaceHoursReparse).run();
} else {
await app.get(PlaceSyncStatus).report(region);
}
} finally {
await app.close();
}
}
main().catch((error: unknown) => {
// 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
Logger.error(
error instanceof Error ? error.message : String(error),
'PlaceSync',
);
process.exitCode = 1;
});
@@ -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,137 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlaceTables1793597729768 implements MigrationInterface {
name = 'AddPlaceTables1793597729768';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "place_curation" ("place_id" uuid NOT NULL, "verdict" text NOT NULL DEFAULT 'pending', "reason_ko" text, "reason_i18n" jsonb, "hours_text" text, "rest_text" text, "hours" jsonb, "pet_policy" text NOT NULL DEFAULT 'unknown', "pet_sizes" text array NOT NULL DEFAULT '{}', "pet_conditions" text, "setting" text, "official_url" text, "verified_method" text, "verified_at" date, "verified_by" text, "recheck_due_at" date, "memo" text, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "ck_place_curation_verified" CHECK ("verified_at" IS NULL OR "verified_method" IS NOT NULL), CONSTRAINT "ck_place_curation_verified_method" CHECK ("verified_method" IS NULL OR "verified_method" IN ('phone', 'official_channel', 'visit')), CONSTRAINT "ck_place_curation_setting" CHECK ("setting" IS NULL OR "setting" IN ('indoor', 'outdoor', 'both')), CONSTRAINT "ck_place_curation_pet_sizes" CHECK ("pet_sizes" <@ ARRAY['small', 'medium', 'large']::text[]), CONSTRAINT "ck_place_curation_pet_policy" CHECK ("pet_policy" IN ('allowed', 'partial', 'not_allowed', 'unknown')), CONSTRAINT "ck_place_curation_verdict" CHECK ("verdict" IN ('recommend', 'not_recommend', 'pending')), CONSTRAINT "PK_645f6298438801472c60ce4195c" PRIMARY KEY ("place_id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_curation_recheck_due_at" ON "place_curation" ("recheck_due_at") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_curation_verdict" ON "place_curation" ("verdict") `,
);
await queryRunner.query(
`CREATE TABLE "place" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "category" text NOT NULL, "source_category" text, "name" text NOT NULL, "branch_name" text, "address_road" text, "address_jibun" text, "lat" double precision NOT NULL, "lng" double precision NOT NULL, "ldong_regn_cd" text, "ldong_signgu_cd" text, "adong_cd" text, "phone" text, "homepage" text, "overview" text, "public_hours_text" text, "public_rest_text" text, "public_hours" jsonb, "public_pet" jsonb, "public_pet_policy" text, "public_extra" jsonb, "image_url" text, "image_thumb_url" text, "image_license" text, "status" text NOT NULL DEFAULT 'active', "source_modified_at" TIMESTAMP WITH TIME ZONE, "source_snapshot" text, "last_seen_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_place_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_place_lat_lng" CHECK ("lat" BETWEEN -90 AND 90 AND "lng" BETWEEN -180 AND 180), CONSTRAINT "ck_place_image_license" CHECK ("image_license" IS NULL OR "image_license" IN ('Type1', 'Type3')), CONSTRAINT "ck_place_public_pet_policy" CHECK ("public_pet_policy" IS NULL OR "public_pet_policy" IN ('allowed', 'partial')), CONSTRAINT "ck_place_status" CHECK ("status" IN ('active', 'hidden', 'gone')), CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging')), CONSTRAINT "ck_place_source" CHECK ("source" IN ('tour', 'store')), CONSTRAINT "PK_96ab91d43aa89c5de1b59ee7cca" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_status" ON "place" ("status") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_category" ON "place" ("category") `,
);
await queryRunner.query(
`CREATE INDEX "ix_place_lat_lng" ON "place" ("lat", "lng") `,
);
await queryRunner.query(
`CREATE TABLE "place_sync_run" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "job" text NOT NULL, "scope" text, "started_at" TIMESTAMP WITH TIME ZONE NOT NULL, "finished_at" TIMESTAMP WITH TIME ZONE, "fetched" integer NOT NULL DEFAULT '0', "inserted" integer NOT NULL DEFAULT '0', "updated" integer NOT NULL DEFAULT '0', "hidden" integer NOT NULL DEFAULT '0', "gone" integer NOT NULL DEFAULT '0', "calls" jsonb NOT NULL DEFAULT '{}', "errors" jsonb NOT NULL DEFAULT '[]', "snapshot" text, CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure')), CONSTRAINT "PK_0262dd8226b47bf7ee739b633d6" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_place_sync_run_job_started_at" ON "place_sync_run" ("job", "started_at") `,
);
await queryRunner.query(
`CREATE TABLE "place_i18n" ("place_id" uuid NOT NULL, "lang" text NOT NULL, "name" text NOT NULL, "address" text, "overview" text, "origin" text NOT NULL, "origin_id" text, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "ck_place_i18n_origin" CHECK ("origin" IN ('tour_en', 'machine', 'curator')), CONSTRAINT "ck_place_i18n_lang" CHECK ("lang" IN ('en', 'ja', 'zh-Hans', 'zh-Hant')), CONSTRAINT "PK_42a28a7fcfecb7c73462df320ae" PRIMARY KEY ("place_id", "lang"))`,
);
await queryRunner.query(
`CREATE TABLE "festival_event" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source_id" text NOT NULL, "kind" text NOT NULL, "name" text NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "lat" double precision, "lng" double precision, "address" text, "place_id" uuid, "fee_text" text, "playtime_text" text, "overview" text, "image_url" text, "image_license" text, "i18n" jsonb, "status" text NOT NULL DEFAULT 'active', "last_seen_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id"), CONSTRAINT "ck_festival_event_period" CHECK ("end_date" >= "start_date"), CONSTRAINT "ck_festival_event_image_license" CHECK ("image_license" IS NULL OR "image_license" IN ('Type1', 'Type3')), CONSTRAINT "ck_festival_event_status" CHECK ("status" IN ('active', 'hidden')), CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event')), CONSTRAINT "PK_01f428b159e675773743bb1d220" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_lat_lng" ON "festival_event" ("lat", "lng") `,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_period" ON "festival_event" ("start_date", "end_date") `,
);
await queryRunner.query(
`ALTER TABLE "place_curation" ADD CONSTRAINT "FK_645f6298438801472c60ce4195c" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "place_i18n" ADD CONSTRAINT "FK_add719bd193db01b37fc0486c13" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "FK_9359619412cd3d514d041894f3c" FOREIGN KEY ("place_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(`CREATE VIEW "place_listing" AS
SELECT
p.id,
p.source,
p.source_id,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active') AS is_searchable,
(p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,
CASE
WHEN p.status <> 'active' THEN NULL
WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'public',
'VIEW',
'place_listing',
"SELECT\n p.id,\n p.source,\n p.source_id,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active') AS is_searchable,\n (p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN p.status <> 'active' THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id",
],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'place_listing', 'public'],
);
await queryRunner.query(`DROP VIEW "place_listing"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "FK_9359619412cd3d514d041894f3c"`,
);
await queryRunner.query(
`ALTER TABLE "place_i18n" DROP CONSTRAINT "FK_add719bd193db01b37fc0486c13"`,
);
await queryRunner.query(
`ALTER TABLE "place_curation" DROP CONSTRAINT "FK_645f6298438801472c60ce4195c"`,
);
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_period"`);
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_lat_lng"`);
await queryRunner.query(`DROP TABLE "festival_event"`);
await queryRunner.query(`DROP TABLE "place_i18n"`);
await queryRunner.query(
`DROP INDEX "public"."ix_place_sync_run_job_started_at"`,
);
await queryRunner.query(`DROP TABLE "place_sync_run"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_lat_lng"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_category"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_status"`);
await queryRunner.query(`DROP TABLE "place"`);
await queryRunner.query(`DROP INDEX "public"."ix_place_curation_verdict"`);
await queryRunner.query(
`DROP INDEX "public"."ix_place_curation_recheck_due_at"`,
);
await queryRunner.query(`DROP TABLE "place_curation"`);
}
}
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 관광공사 동기화 준비:
* - source_type: 관광타입(contenttypeid) — 소개정보(detailIntro2) 조회에 필요
* - detail_synced_at: 상세를 받은 시각 — 하루 한도로 나뉜 초기 적재를 이어서 받는다
* - 분류에 'other'(교육시설·연회장 등) 추가
*
* ⚠️ CHECK 제약 변경은 migration:generate 가 감지하지 못해(이름이 같으면 같은 제약으로 본다) 직접 적었다.
*/
export class AddPlaceSourceTypeAndDetailSync1793599990257 implements MigrationInterface {
name = 'AddPlaceSourceTypeAndDetailSync1793599990257';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "place" ADD "source_type" text`);
await queryRunner.query(
`ALTER TABLE "place" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging', 'other'))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 'other' 로 들어간 장소가 있으면 되돌리기 전에 정리해야 한다(제약 위반으로 실패한다)
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging'))`,
);
await queryRunner.query(
`ALTER TABLE "place" DROP COLUMN "detail_synced_at"`,
);
await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "source_type"`);
}
}
@@ -0,0 +1,123 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlaceMergeAndListingFlags1793606583333 implements MigrationInterface {
name = 'AddPlaceMergeAndListingFlags1793606583333';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'place_listing', 'public'],
);
await queryRunner.query(`DROP VIEW "place_listing"`);
await queryRunner.query(`ALTER TABLE "place" ADD "merged_into_id" uuid`);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "FK_d525a5b0e0dccec5c1165637dc7" FOREIGN KEY ("merged_into_id") REFERENCES "place"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(`CREATE VIEW "place_listing" AS
SELECT
p.id,
p.source,
p.source_id,
p.source_category,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active' AND p.merged_into_id IS NULL) AS is_searchable,
COALESCE(p.source_category = 'SH040300' AND p.name !~ '^(현대백화점|더현대|롯데백화점|대구백화점|신세계|대구신세계|동아백화점)', false) AS is_brand_store,
(p.status = 'active' AND p.merged_into_id IS NULL AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,
CASE
WHEN NOT (p.status = 'active' AND p.merged_into_id IS NULL) THEN NULL
WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'public',
'VIEW',
'place_listing',
"SELECT\n p.id,\n p.source,\n p.source_id,\n p.source_category,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active' AND p.merged_into_id IS NULL) AS is_searchable,\n COALESCE(p.source_category = 'SH040300' AND p.name !~ '^(현대백화점|더현대|롯데백화점|대구백화점|신세계|대구신세계|동아백화점)', false) AS is_brand_store,\n (p.status = 'active' AND p.merged_into_id IS NULL AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN NOT (p.status = 'active' AND p.merged_into_id IS NULL) THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id",
],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'place_listing', 'public'],
);
await queryRunner.query(`DROP VIEW "place_listing"`);
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "FK_d525a5b0e0dccec5c1165637dc7"`,
);
await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "merged_into_id"`);
await queryRunner.query(`CREATE VIEW "place_listing" AS SELECT
p.id,
p.source,
p.source_id,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active') AS is_searchable,
(p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,
CASE
WHEN p.status <> 'active' THEN NULL
WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id`);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'public',
'VIEW',
'place_listing',
"SELECT\n p.id,\n p.source,\n p.source_id,\n p.category,\n p.name,\n p.branch_name,\n p.address_road,\n p.lat,\n p.lng,\n p.ldong_regn_cd,\n p.ldong_signgu_cd,\n p.image_url,\n p.image_thumb_url,\n p.image_license,\n p.status,\n c.verdict,\n c.verified_at,\n c.setting,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN p.public_hours END AS hours,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN c.hours_text ELSE p.public_hours_text END AS hours_text,\n CASE WHEN c.verified_at IS NOT NULL AND c.hours IS NOT NULL THEN 'curation' WHEN p.public_hours->>'status' IN ('parsed', 'always_open') THEN 'public' ELSE 'unknown' END AS hours_origin,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN c.pet_policy WHEN p.public_pet_policy IS NOT NULL THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,\n CASE WHEN c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown' THEN 'curation' WHEN p.public_pet_policy IS NOT NULL THEN 'public' ELSE 'unknown' END AS pet_origin,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,\n CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,\n (p.status = 'active') AS is_searchable,\n (p.status = 'active' AND COALESCE(c.verdict = 'recommend' AND c.verified_at IS NOT NULL, false)) AS is_home_pick,\n CASE\n WHEN p.status <> 'active' THEN NULL\n WHEN c.verdict = 'recommend' AND c.verified_at IS NOT NULL THEN 1\n WHEN c.verdict = 'not_recommend' THEN NULL\n WHEN p.source = 'tour' AND p.category IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe') THEN 2\n END AS ai_tier\nFROM place p\nLEFT JOIN place_curation c ON c.place_id = p.id",
],
);
}
}
@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFestivalEventSyncColumns1793700318741 implements MigrationInterface {
name = 'AddFestivalEventSyncColumns1793700318741';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source_category" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "event_place" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "ldong_regn_cd" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "ldong_signgu_cd" text`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "phone" text`);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "homepage" text`);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "image_thumb_url" text`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source_modified_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`CREATE INDEX "ix_festival_event_region" ON "festival_event" ("ldong_regn_cd") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."ix_festival_event_region"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "detail_synced_at"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source_modified_at"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "image_thumb_url"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "homepage"`,
);
await queryRunner.query(`ALTER TABLE "festival_event" DROP COLUMN "phone"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "ldong_signgu_cd"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "ldong_regn_cd"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "event_place"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source_category"`,
);
}
}
@@ -0,0 +1,92 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 행사 출처를 여럿 받을 준비(KOPIS 공연·한국문화정보원 전시 등):
* - festival_event.source + (source, source_id) 유니크 — 관광공사 contentid 단독 유니크였다
* - 장르·예매 링크·공연장(event_venue) — KOPIS 공연은 좌표가 공연장 상세에만 있다
* - 행사 종류에 'exhibition', 동기화 작업에 'kopis'·'kcisa' 추가
*
* ⚠️ CHECK 제약 변경(ck_festival_event_kind·ck_place_sync_run_job)은 migration:generate 가 감지하지 못해 직접 적었다.
* 기존 행사는 기본값으로 source='tour' 가 된다.
*/
export class AddEventSourceAndVenue1793704967449 implements MigrationInterface {
name = 'AddEventSourceAndVenue1793704967449';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "event_venue" ("id" uuid NOT NULL DEFAULT gen_random_uuid(), "source" text NOT NULL, "source_id" text NOT NULL, "name" text NOT NULL, "address" text, "lat" double precision, "lng" double precision, "phone" text, "homepage" text, "synced_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "uq_event_venue_source" UNIQUE ("source", "source_id"), CONSTRAINT "ck_event_venue_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca')), CONSTRAINT "PK_128347780df4ef90d1426da6c77" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "source" text NOT NULL DEFAULT 'tour'`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "genre" text`);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD "booking_url" text`,
);
await queryRunner.query(`ALTER TABLE "festival_event" ADD "venue_id" uuid`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_source" CHECK ("source" IN ('tour', 'kopis', 'kcisa', 'dgfca'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "uq_festival_event_source" UNIQUE ("source", "source_id")`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "FK_93324676cbe20bf271722f548b0" FOREIGN KEY ("venue_id") REFERENCES "event_venue"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
// 직접 적은 CHECK 제약 변경
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event', 'exhibition'))`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure', 'kopis', 'kcisa'))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 되돌리기 전에 kopis·kcisa 실행 기록과 전시·다른 출처 행사를 정리해야 한다(제약 위반으로 실패한다)
await queryRunner.query(
`ALTER TABLE "place_sync_run" DROP CONSTRAINT "ck_place_sync_run_job"`,
);
await queryRunner.query(
`ALTER TABLE "place_sync_run" ADD CONSTRAINT "ck_place_sync_run_job" CHECK ("job" IN ('tour_kor', 'tour_eng', 'tour_pet', 'store', 'event', 'closure'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_kind"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "ck_festival_event_kind" CHECK ("kind" IN ('festival', 'performance', 'event'))`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "FK_93324676cbe20bf271722f548b0"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "uq_festival_event_source"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP CONSTRAINT "ck_festival_event_source"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" ADD CONSTRAINT "UQ_4bbebad7c067ed4c90aa628ba3e" UNIQUE ("source_id")`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "venue_id"`,
);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "booking_url"`,
);
await queryRunner.query(`ALTER TABLE "festival_event" DROP COLUMN "genre"`);
await queryRunner.query(
`ALTER TABLE "festival_event" DROP COLUMN "source"`,
);
await queryRunner.query(`DROP TABLE "event_venue"`);
}
}
@@ -0,0 +1,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];
}
@@ -0,0 +1,105 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import { EVENT_KINDS, IMAGE_LICENSES } from '../place.constants';
import type { EventKind, ImageLicense } from '../place.constants';
import { PlaceAttributionDto } from './place-detail.dto';
/** 축제·공연·행사 목록의 한 건 */
export class EventListItemDto {
@ApiProperty({ description: '행사 id', example: '7d2e…' })
id!: string;
@ApiProperty({ description: '이름', example: '수성빛예술제' })
name!: string;
@ApiProperty({ description: '분류', enum: EVENT_KINDS, example: 'festival' })
kind!: EventKind;
@ApiProperty({ description: '시작일(YYYY-MM-DD)', example: '2026-11-27' })
startDate!: string;
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-12-06' })
endDate!: string;
@ApiProperty({
description: '장르(연극·뮤지컬·클래식·전시 …)',
nullable: true,
example: '뮤지컬',
})
genre!: string | null;
@ApiProperty({ description: '오늘(한국 날짜) 진행 중인지', example: false })
ongoing!: boolean;
@ApiProperty({ description: '주소', nullable: true })
address!: string | null;
@ApiProperty({
description: '행사 장소 이름',
nullable: true,
example: '수성못 일원',
})
eventPlace!: string | null;
@ApiProperty({ description: '위도(없을 수 있다)', nullable: true })
lat!: number | null;
@ApiProperty({ description: '경도(없을 수 있다)', nullable: true })
lng!: number | null;
@ApiProperty({
description: '기준 좌표에서의 거리(m). lat·lng 없이 조회하면 null',
nullable: true,
})
distanceM!: number | null;
@ApiProperty({ description: '요금', nullable: true, example: '무료' })
feeText!: string | null;
@ApiProperty({ description: '작은 사진 URL', nullable: true })
imageThumbUrl!: string | null;
@ApiProperty({
description: '사진 공공누리 유형 — 출처 표시 필요(Type3 은 변경 금지)',
enum: IMAGE_LICENSES,
nullable: true,
})
imageLicense!: ImageLicense | null;
}
export class EventListResponseDto {
@ApiProperty({ type: [EventListItemDto] })
items!: EventListItemDto[];
@ApiProperty({ type: PageInfoDto })
pageInfo!: PageInfoDto;
}
/** 축제·공연·행사 상세 */
export class EventDetailDto extends EventListItemDto {
@ApiProperty({ description: '소개', nullable: true })
overview!: string | null;
@ApiProperty({
description: '공연·운영 시간 원문',
nullable: true,
example: '17:00~22:00',
})
playtimeText!: string | null;
@ApiProperty({ description: '홈페이지', nullable: true })
homepage!: string | null;
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '예매·관람 안내 링크', nullable: true })
bookingUrl!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -0,0 +1,109 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
Matches,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { EVENT_KINDS } from '../place.constants';
import type { EventKind } from '../place.constants';
/** 기간 기본값·한도(일) */
export const EVENT_RANGE_DEFAULT_DAYS = 30;
export const EVENT_RANGE_MAX_DAYS = 92;
/** 근처 행사 반경(m) */
export const EVENT_RADIUS_DEFAULT_M = 5_000;
export const EVENT_RADIUS_MIN_M = 100;
/** 대구 근교(경북) 행사까지 거리순으로 보려면 100km 이상이 필요하다(포항 약 85km · 청송 약 100km) */
export const EVENT_RADIUS_MAX_M = 150_000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const toList = ({ value }: { value: unknown }): unknown =>
typeof value === 'string'
? value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: value;
/** 축제·공연·행사 목록 조건 */
export class EventSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description: '기간 시작(YYYY-MM-DD, 한국 날짜). 생략하면 오늘',
example: '2026-11-07',
})
@IsOptional()
@Matches(DATE_RE, { message: '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.' })
from?: string;
@ApiPropertyOptional({
description: `기간 끝(YYYY-MM-DD). 생략하면 시작일 + ${EVENT_RANGE_DEFAULT_DAYS}일, 최대 ${EVENT_RANGE_MAX_DAYS}일`,
example: '2026-11-08',
})
@IsOptional()
@Matches(DATE_RE, { message: '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.' })
to?: string;
@ApiPropertyOptional({
description:
'분류(쉼표로 여러 개): festival 축제 · performance 공연 · event 행사 · exhibition 전시',
enum: EVENT_KINDS,
isArray: true,
example: 'festival,event',
})
@IsOptional()
@Transform(toList)
@IsArray({ message: '행사 분류 값이 올바르지 않습니다.' })
@IsIn(EVENT_KINDS, {
each: true,
message: '행사 분류 값이 올바르지 않습니다.',
})
kind?: EventKind[];
@ApiPropertyOptional({
description:
'기준 위도 — lng 와 함께 보내면 반경 안 행사를 거리순으로 준다',
example: 35.8282,
})
@ValidateIf(
(o: EventSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLatitude({ message: '위도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.' })
lat?: number;
@ApiPropertyOptional({ description: '기준 경도', example: 128.6176 })
@ValidateIf(
(o: EventSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLongitude({
message: '경도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.',
})
lng?: number;
@ApiPropertyOptional({
description: `반경(m, ${EVENT_RADIUS_MIN_M}~${EVENT_RADIUS_MAX_M})`,
default: EVENT_RADIUS_DEFAULT_M,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '반경은 정수(m)로 입력해 주세요.' })
@Min(EVENT_RADIUS_MIN_M, {
message: `반경은 ${EVENT_RADIUS_MIN_M}m 이상이어야 합니다.`,
})
@Max(EVENT_RADIUS_MAX_M, {
message: `반경은 ${EVENT_RADIUS_MAX_M}m 이하여야 합니다.`,
})
radius?: number;
}
@@ -0,0 +1,120 @@
import { ApiProperty } from '@nestjs/swagger';
import { PET_SIZES } from '../place.constants';
import type { PetSize } from '../place.constants';
import { PlaceListItemDto } from './place-list-item.dto';
const HOURS_STATUSES = ['parsed', 'always_open', 'varies', 'unknown'] as const;
export class PlaceHoursDto {
@ApiProperty({
description:
'영업시간 원문(운영자 확인 값이 있으면 그것, 없으면 공공데이터)',
nullable: true,
example: '11:00~21:00 (준비 시간 15:30~17:00)',
})
text!: string | null;
@ApiProperty({
description: '쉬는 날 원문',
nullable: true,
example: '매주 월요일',
})
restText!: string | null;
@ApiProperty({
description:
'해석 결과 — varies(점포별 상이)·unknown 이면 시간을 단정하지 않는다',
enum: HOURS_STATUSES,
})
status!: (typeof HOURS_STATUSES)[number];
}
export class PlacePetDto {
@ApiProperty({
description: '반려견 크기(운영자 확인)',
enum: PET_SIZES,
isArray: true,
nullable: true,
})
sizes!: PetSize[] | null;
@ApiProperty({
description:
'조건 — 운영자 확인 값 또는 공공데이터 원문(예: "목줄 착용", "켄넬 필수")',
nullable: true,
example: '전 견종 동반 가능 · 목줄 착용',
})
conditions!: string | null;
}
export class PlaceCurationInfoDto {
@ApiProperty({
description: '추천 이유(운영자가 직접 쓴 글)',
nullable: true,
example: '골목 벽화를 따라 30분 산책하기 좋아요.',
})
reason!: string | null;
@ApiProperty({ description: '업체 공식 채널', nullable: true })
officialUrl!: string | null;
}
export class PlaceAttributionDto {
@ApiProperty({
description: '정보 출처 표기',
example: '한국관광공사',
})
provider!: string;
@ApiProperty({
description:
'원본 기준 시점(관광공사 수정일 YYYY-MM-DD · 상가정보 기준년월 YYYYMM)',
nullable: true,
example: '2026-10-15',
})
asOf!: string | null;
}
/** 장소 상세 — 목록 필드에 상세 정보를 더한다 */
export class PlaceDetailDto extends PlaceListItemDto {
@ApiProperty({ description: '지번 주소', nullable: true })
addressJibun!: string | null;
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '홈페이지', nullable: true })
homepage!: string | null;
@ApiProperty({ description: '소개(공공데이터)', nullable: true })
overview!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@ApiProperty({ type: PlaceHoursDto })
hours!: PlaceHoursDto;
@ApiProperty({ type: PlacePetDto })
pet!: PlacePetDto;
@ApiProperty({
description: '이용 안내(입장료·주차·문의처·대표 메뉴 등 공공데이터 원문)',
type: 'object',
additionalProperties: { type: 'string' },
nullable: true,
example: { fee: '무료', parking: '가능' },
})
extra!: Record<string, string> | null;
@ApiProperty({
type: PlaceCurationInfoDto,
nullable: true,
description: '운영자 추천 정보 — 추천(O)하고 확인한 장소만',
})
curation!: PlaceCurationInfoDto | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -0,0 +1,128 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
IMAGE_LICENSES,
PET_POLICIES,
PLACE_CATEGORIES,
PLACE_SETTINGS,
PLACE_SOURCES,
} from '../place.constants';
import type {
ImageLicense,
PetPolicy,
PlaceCategory,
PlaceSetting,
PlaceSource,
} from '../place.constants';
/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */
const VALUE_ORIGINS = ['curation', 'public', 'unknown'] as const;
export type ValueOriginDto = (typeof VALUE_ORIGINS)[number];
/** 검색·지도 목록의 장소 한 건 */
export class PlaceListItemDto {
@ApiProperty({
description: '장소 id',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
id!: string;
@ApiProperty({ description: '이름', example: '김광석다시그리기길' })
name!: string;
@ApiProperty({ description: '지점명', nullable: true, example: null })
branchName!: string | null;
@ApiProperty({
description: '분류',
enum: PLACE_CATEGORIES,
example: 'attraction',
})
category!: PlaceCategory;
@ApiProperty({
description: '데이터 출처(tour 한국관광공사 · store 소상공인 상가정보)',
enum: PLACE_SOURCES,
})
source!: PlaceSource;
@ApiProperty({
description: '주소',
nullable: true,
example: '대구광역시 중구 달구벌대로 2238',
})
address!: string | null;
@ApiProperty({ description: '위도', example: 35.8597 })
lat!: number;
@ApiProperty({ description: '경도', example: 128.6067 })
lng!: number;
@ApiProperty({
description: '기준 좌표에서의 거리(m). lat·lng 없이 조회하면 null',
nullable: true,
example: 1057,
})
distanceM!: number | null;
@ApiProperty({ description: '작은 사진 URL', nullable: true })
imageThumbUrl!: string | null;
@ApiProperty({
description:
'사진 공공누리 유형 — 화면에 출처를 표시해야 한다(Type3 은 변경 금지)',
enum: IMAGE_LICENSES,
nullable: true,
})
imageLicense!: ImageLicense | null;
@ApiProperty({
description:
'영업시간 출처(curation 운영자 확인 · public 공공데이터 · unknown 미확인)',
enum: VALUE_ORIGINS,
})
hoursOrigin!: ValueOriginDto;
@ApiProperty({
description:
'지금(한국 시간) 영업 중인지. 알 수 없으면 null — "영업시간 미확인 · 방문 전 확인"',
nullable: true,
example: true,
})
openNow!: boolean | null;
@ApiProperty({
description: '반려동물 동반',
enum: PET_POLICIES,
example: 'allowed',
})
petPolicy!: PetPolicy;
@ApiProperty({ description: '반려동물 정보 출처', enum: VALUE_ORIGINS })
petOrigin!: ValueOriginDto;
@ApiProperty({
description: '실내/실외(운영자가 확인한 장소만)',
enum: PLACE_SETTINGS,
nullable: true,
})
setting!: PlaceSetting | null;
@ApiProperty({
description: '운영자 확인일(YYYY-MM-DD). 확인하지 않았으면 null',
nullable: true,
example: null,
})
verifiedAt!: string | null;
}
/** 장소 목록 응답 본문(인터셉터가 { success, data } 로 한 번 더 감싼다) */
export class PlaceListResponseDto {
@ApiProperty({ type: [PlaceListItemDto] })
items!: PlaceListItemDto[];
@ApiProperty({ type: PageInfoDto })
pageInfo!: PageInfoDto;
}
@@ -0,0 +1,136 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
IsString,
Length,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { PLACE_CATEGORIES } from '../place.constants';
import type { PlaceCategory } from '../place.constants';
/** 반경 기본값·한도(m) */
export const PLACE_RADIUS_DEFAULT_M = 1_500;
export const PLACE_RADIUS_MIN_M = 100;
export const PLACE_RADIUS_MAX_M = 20_000;
/** 쿼리 문자열 'true'/'false' → boolean. 그 밖의 값은 그대로 두어 검증에서 걸리게 한다 */
const toBoolean = ({ value }: { value: unknown }): unknown => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
};
/** 'cafe,food' → ['cafe', 'food'] (같은 키를 여러 번 보내도 된다) */
const toList = ({ value }: { value: unknown }): unknown =>
typeof value === 'string'
? value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: value;
/** 장소 검색·근처 목록 조건 */
export class PlaceSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'이름 검색어(1~50자). 있으면 사후면세점 브랜드 매장도 검색된다',
example: '김광석',
})
@IsOptional()
@IsString({ message: '검색어는 문자열로 입력해 주세요.' })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@Length(1, 50, { message: '검색어는 1~50자로 입력해 주세요.' })
q?: string;
@ApiPropertyOptional({
description: '분류(쉼표로 여러 개)',
enum: PLACE_CATEGORIES,
isArray: true,
example: 'cafe,food',
})
@IsOptional()
@Transform(toList)
@IsArray({ message: '분류 값이 올바르지 않습니다.' })
@IsIn(PLACE_CATEGORIES, {
each: true,
message: '분류 값이 올바르지 않습니다.',
})
category?: PlaceCategory[];
@ApiPropertyOptional({
description: '기준 위도 — lng 와 함께 보내면 거리순으로 반경 안만 찾는다',
example: 35.865,
})
@ValidateIf(
(o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLatitude({ message: '위도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.' })
lat?: number;
@ApiPropertyOptional({ description: '기준 경도', example: 128.5966 })
@ValidateIf(
(o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLongitude({
message: '경도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.',
})
lng?: number;
@ApiPropertyOptional({
description: `반경(m, ${PLACE_RADIUS_MIN_M}~${PLACE_RADIUS_MAX_M}). lat·lng 가 있을 때만 쓴다`,
default: PLACE_RADIUS_DEFAULT_M,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '반경은 정수(m)로 입력해 주세요.' })
@Min(PLACE_RADIUS_MIN_M, {
message: `반경은 ${PLACE_RADIUS_MIN_M}m 이상이어야 합니다.`,
})
@Max(PLACE_RADIUS_MAX_M, {
message: `반경은 ${PLACE_RADIUS_MAX_M}m 이하여야 합니다.`,
})
radius?: number;
@ApiPropertyOptional({
description: '반려동물 동반 가능(전 구역·일부 구역) 장소만',
example: true,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'pet 은 true 또는 false 로 입력해 주세요.' })
pet?: boolean;
@ApiPropertyOptional({
description: '실내(실내·실내외) 장소만 — 운영자가 확인한 장소만 해당한다',
example: false,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' })
indoor?: boolean;
@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;
}
@@ -0,0 +1,168 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import {
EVENT_KINDS,
EVENT_SOURCES,
EVENT_STATUSES,
IMAGE_LICENSES,
sqlIn,
} from '../place.constants';
import type {
EventKind,
EventSource,
EventStatus,
ImageLicense,
} from '../place.constants';
import { EventVenue } from './event-venue.entity';
import { Place } from './place.entity';
/**
* 축제·공연·행사·전시. 출처는 관광공사(searchFestival2)·KOPIS(공연)·한국문화정보원(전시) 등이다.
* 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'에 쓴다.
* "상시 프로그램 제외"(종류별 기간 상한, EVENT_MAX_SPAN_DAYS)는 저장할 때가 아니라 조회할 때 적용한다.
*/
@Entity({ name: 'festival_event' })
@Unique('uq_festival_event_source', ['source', 'sourceId'])
@Index('ix_festival_event_period', ['startDate', 'endDate'])
@Index('ix_festival_event_lat_lng', ['lat', 'lng'])
@Index('ix_festival_event_region', ['ldongRegnCd'])
@Check('ck_festival_event_source', sqlIn('source', EVENT_SOURCES))
@Check('ck_festival_event_kind', sqlIn('kind', EVENT_KINDS))
@Check('ck_festival_event_status', sqlIn('status', EVENT_STATUSES))
@Check(
'ck_festival_event_image_license',
sqlIn('image_license', IMAGE_LICENSES, true),
)
@Check('ck_festival_event_period', `"end_date" >= "start_date"`)
export class FestivalEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text', default: 'tour' })
source!: EventSource;
/** 원본 id — 관광공사 contentid · KOPIS mt20id · 문화정보원 seq. (source, sourceId) 가 유니크 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
kind!: EventKind;
/** 원본 분류 코드(lclsSystm3, 예: EV010200 문화예술축제 · KOPIS 장르코드 · 문화정보원 realmCode) */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
/** 장르 표시용(연극·뮤지컬·클래식·전시 …) */
@Column({ type: 'text', nullable: true })
genre!: string | null;
/** 예매·관람 안내 링크 */
@Column({ type: 'text', nullable: true })
bookingUrl!: string | null;
/** 공연장·전시장(좌표를 공연장에서 얻는 출처가 있다 — KOPIS 공연시설) */
@Column({ type: 'uuid', nullable: true })
venueId!: string | null;
@ManyToOne(() => EventVenue, { nullable: true })
@JoinColumn({ name: 'venue_id' })
venue?: EventVenue | null;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'date' })
startDate!: string;
@Column({ type: 'date' })
endDate!: string;
@Column({ type: 'double precision', nullable: true })
lat!: number | null;
@Column({ type: 'double precision', nullable: true })
lng!: number | null;
@Column({ type: 'text', nullable: true })
address!: string | null;
/** 행사 장소 이름(eventplace, 예: "수성못 일원") */
@Column({ type: 'text', nullable: true })
eventPlace!: string | null;
/** 법정동 시·도(2자리) — 동기화 범위 */
@Column({ type: 'text', nullable: true })
ldongRegnCd!: string | null;
@Column({ type: 'text', nullable: true })
ldongSignguCd!: string | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
/** 개최 장소가 place 에 있으면 연결한다(선택) */
@Column({ type: 'uuid', nullable: true })
placeId!: string | null;
@ManyToOne(() => Place, { nullable: true })
@JoinColumn({ name: 'place_id' })
place?: Place | null;
/** 요금 — 관광공사 usetimefestival 은 이름과 달리 요금이다 */
@Column({ type: 'text', nullable: true })
feeText!: string | null;
/** 공연·운영 시간(playtime) */
@Column({ type: 'text', nullable: true })
playtimeText!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageThumbUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
/** 언어별 텍스트 { en: { name, overview } } */
@Column({ type: 'jsonb', nullable: true })
i18n!: Record<string, Record<string, string>> | null;
@Column({ type: 'text', default: 'active' })
status!: EventStatus;
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
/** 관광공사 modifiedtime */
@Column({ type: 'timestamptz', nullable: true })
sourceModifiedAt!: Date | null;
/** 상세(소개·요금·공연 시간·장소)를 받은 시각. null 이면 받아야 한다 */
@Column({ type: 'timestamptz', nullable: true })
detailSyncedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,130 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
CURATION_VERDICTS,
PET_POLICIES,
PET_SIZES,
PLACE_SETTINGS,
VERIFY_METHODS,
sqlIn,
sqlValues,
} from '../place.constants';
import type {
CurationVerdict,
PetPolicy,
PetSize,
PlaceSetting,
VerifyMethod,
} from '../place.constants';
import { Place } from './place.entity';
/**
* 운영자가 확인한 장소 정보 (place 1 : 0..1). 큐레이션 표의 오른쪽 칸이 그대로 옮겨 온다.
* 동기화는 이 테이블을 읽지도 쓰지도 않는다.
* 카카오·네이버에서 본 정보는 어떤 칸에도 넣지 않는다 — 확인 근거는 업체 본인 채널·전화·방문뿐이다.
*/
@Entity()
@Index('ix_place_curation_verdict', ['verdict'])
@Index('ix_place_curation_recheck_due_at', ['recheckDueAt'])
@Check('ck_place_curation_verdict', sqlIn('verdict', CURATION_VERDICTS))
@Check('ck_place_curation_pet_policy', sqlIn('pet_policy', PET_POLICIES))
@Check(
'ck_place_curation_pet_sizes',
`"pet_sizes" <@ ARRAY[${sqlValues(PET_SIZES)}]::text[]`,
)
@Check('ck_place_curation_setting', sqlIn('setting', PLACE_SETTINGS, true))
@Check(
'ck_place_curation_verified_method',
sqlIn('verified_method', VERIFY_METHODS, true),
)
// 확인일을 적었다면 무엇으로 확인했는지도 반드시 남긴다
@Check(
'ck_place_curation_verified',
`"verified_at" IS NULL OR "verified_method" IS NOT NULL`,
)
export class PlaceCuration {
@PrimaryColumn({ type: 'uuid' })
placeId!: string;
@OneToOne(() => Place, (place) => place.curation)
@JoinColumn({ name: 'place_id' })
place?: Relation<Place>;
/** 데이트추천(O/X) */
@Column({ type: 'text', default: 'pending' })
verdict!: CurationVerdict;
/** 추천이유 — 큐레이터가 직접 쓴 문장만(리뷰 문장 복사 금지) */
@Column({ type: 'text', nullable: true })
reasonKo!: string | null;
/** 추천이유 번역 { en: … } — 자체 작성 글이라 기계 번역 가능 */
@Column({ type: 'jsonb', nullable: true })
reasonI18n!: Record<string, string> | null;
/** 영업시간(확인) 원문 */
@Column({ type: 'text', nullable: true })
hoursText!: string | null;
/** 휴무(확인) 원문 */
@Column({ type: 'text', nullable: true })
restText!: string | null;
/** hoursText·restText 를 파서로 해석한 결과 */
@Column({ type: 'jsonb', nullable: true })
hours!: ParsedOpeningHours | null;
@Column({ type: 'text', default: 'unknown' })
petPolicy!: PetPolicy;
@Column({ type: 'text', array: true, default: () => `'{}'` })
petSizes!: PetSize[];
/** "테라스석만", "켄넬 필수" 등 */
@Column({ type: 'text', nullable: true })
petConditions!: string | null;
/** 실내/실외 — 비 오는 날 실내 우선 노출에 쓴다 */
@Column({ type: 'text', nullable: true })
setting!: PlaceSetting | null;
/** 업체 본인 공식 채널(인스타·홈페이지·블로그) */
@Column({ type: 'text', nullable: true })
officialUrl!: string | null;
@Column({ type: 'text', nullable: true })
verifiedMethod!: VerifyMethod | null;
/** 확인일 — 비어 있으면 영업시간·반려동물 값은 앱에 쓰지 않는다(place_listing 뷰) */
@Column({ type: 'date', nullable: true })
verifiedAt!: string | null;
@Column({ type: 'text', nullable: true })
verifiedBy!: string | null;
/** 다시 확인할 날짜(기본: 확인일 + 6개월) */
@Column({ type: 'date', nullable: true })
recheckDueAt!: string | null;
/** 내부 메모 — API 로 내보내지 않는다 */
@Column({ type: 'text', nullable: true })
memo!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,51 @@
import {
Check,
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { I18N_LANGS, I18N_ORIGINS, sqlIn } from '../place.constants';
import type { I18nLang, I18nOrigin } from '../place.constants';
import { Place } from './place.entity';
/**
* 장소의 언어별 표시 텍스트. 한국어는 place 본체에 있다.
* 관광공사 영문은 국문과 contentid 가 달라 좌표·분류로 맞춰 붙인다(origin=tour_en, originId=영문 contentid).
*/
@Entity()
@Check('ck_place_i18n_lang', sqlIn('lang', I18N_LANGS))
@Check('ck_place_i18n_origin', sqlIn('origin', I18N_ORIGINS))
export class PlaceI18n {
@PrimaryColumn({ type: 'uuid' })
placeId!: string;
@PrimaryColumn({ type: 'text' })
lang!: I18nLang;
@ManyToOne(() => Place)
@JoinColumn({ name: 'place_id' })
place?: Place;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
address!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
@Column({ type: 'text' })
origin!: I18nOrigin;
/** 관광공사 영문 contentid 등 원본 식별자 */
@Column({ type: 'text', nullable: true })
originId!: string | null;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,180 @@
import { ViewColumn, ViewEntity } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
AI_TIER2_CATEGORIES,
BRAND_STORE_CATEGORY,
MALL_NAME_PREFIXES,
sqlAlternation,
sqlValues,
} from '../place.constants';
import type {
CurationVerdict,
ImageLicense,
PetPolicy,
PetSize,
PlaceCategory,
PlaceSetting,
PlaceSource,
PlaceStatus,
} from '../place.constants';
/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */
export type ValueOrigin = 'curation' | 'public' | 'unknown';
// 큐레이션 값은 확인일이 있을 때만 쓴다 — 확인 안 된 값을 확인된 것처럼 보이지 않게 한다
const CURATED_HOURS = `c.verified_at IS NOT NULL AND c.hours IS NOT NULL`;
const PUBLIC_HOURS = `p.public_hours->>'status' IN ('parsed', 'always_open')`;
const CURATED_PET = `c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown'`;
const PUBLIC_PET = `p.public_pet_policy IS NOT NULL`;
const HOME_PICK = `c.verdict = 'recommend' AND c.verified_at IS NOT NULL`;
// 노출 중 = 숨김·폐업 추정이 아니고, 다른 출처의 같은 가게로 병합되지도 않은 장소
const LISTED = `p.status = 'active' AND p.merged_into_id IS NULL`;
// 사후면세점 브랜드 매장(백화점·쇼핑몰 자체는 제외) — 목록·필터에서 빼고 이름 검색에만 낸다
const BRAND_STORE = `p.source_category = ${sqlValues([BRAND_STORE_CATEGORY])} AND p.name !~ '^(${sqlAlternation(MALL_NAME_PREFIXES)})'`;
/**
* "앱이 보는 값" — 공공 값과 큐레이션 값을 합치고 노출 플래그를 계산한다.
* 우선순위(큐레이션 > 공공 > 미확인)와 노출 규칙은 **여기서만** 정한다. API·AI 는 이 뷰를 읽는다.
* 노출 규칙(2026-11-02 합의, docs/design/place-db.md §3):
* - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함, 병합된 중복은 제외)
* - is_brand_store: 사후면세점 브랜드 매장 — 조회 API 가 목록·필터에서는 빼고 이름 검색에만 낸다
* - is_home_pick: 추천(O) + 확인일이 있는 장소만 홈 동네 추천에 나온다
* - ai_tier: 1 = 추천(O) + 확인 / 2 = 관광공사 장소(코스 분류, X 아님) / NULL = 그 외 — 확인 안 된 상가정보 매장은 AI 후보가 아니다
*/
export const PLACE_LISTING_SQL = `
SELECT
p.id,
p.source,
p.source_id,
p.source_category,
p.category,
p.name,
p.branch_name,
p.address_road,
p.lat,
p.lng,
p.ldong_regn_cd,
p.ldong_signgu_cd,
p.image_url,
p.image_thumb_url,
p.image_license,
p.status,
c.verdict,
c.verified_at,
c.setting,
CASE WHEN ${CURATED_HOURS} THEN c.hours WHEN ${PUBLIC_HOURS} THEN p.public_hours END AS hours,
CASE WHEN ${CURATED_HOURS} THEN c.hours_text ELSE p.public_hours_text END AS hours_text,
CASE WHEN ${CURATED_HOURS} THEN 'curation' WHEN ${PUBLIC_HOURS} THEN 'public' ELSE 'unknown' END AS hours_origin,
CASE WHEN ${CURATED_PET} THEN c.pet_policy WHEN ${PUBLIC_PET} THEN p.public_pet_policy ELSE 'unknown' END AS pet_policy,
CASE WHEN ${CURATED_PET} THEN 'curation' WHEN ${PUBLIC_PET} THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(${LISTED}) AS is_searchable,
COALESCE(${BRAND_STORE}, false) AS is_brand_store,
(${LISTED} AND COALESCE(${HOME_PICK}, false)) AS is_home_pick,
CASE
WHEN NOT (${LISTED}) THEN NULL
WHEN ${HOME_PICK} THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN (${sqlValues(AI_TIER2_CATEGORIES)}) THEN 2
END AS ai_tier
FROM place p
LEFT JOIN place_curation c ON c.place_id = p.id
`;
@ViewEntity({ name: 'place_listing', expression: PLACE_LISTING_SQL })
export class PlaceListing {
@ViewColumn()
id!: string;
@ViewColumn()
source!: PlaceSource;
@ViewColumn()
sourceId!: string;
/** 원본 분류 코드(lclsSystm3 · indsSclsCd) */
@ViewColumn()
sourceCategory!: string | null;
@ViewColumn()
category!: PlaceCategory;
@ViewColumn()
name!: string;
@ViewColumn()
branchName!: string | null;
@ViewColumn()
addressRoad!: string | null;
@ViewColumn()
lat!: number;
@ViewColumn()
lng!: number;
@ViewColumn()
ldongRegnCd!: string | null;
@ViewColumn()
ldongSignguCd!: string | null;
@ViewColumn()
imageUrl!: string | null;
@ViewColumn()
imageThumbUrl!: string | null;
@ViewColumn()
imageLicense!: ImageLicense | null;
@ViewColumn()
status!: PlaceStatus;
@ViewColumn()
verdict!: CurationVerdict | null;
@ViewColumn()
verifiedAt!: string | null;
@ViewColumn()
setting!: PlaceSetting | null;
/** 큐레이션(확인됨) → 공공(해석 성공) → null */
@ViewColumn()
hours!: ParsedOpeningHours | null;
@ViewColumn()
hoursText!: string | null;
@ViewColumn()
hoursOrigin!: ValueOrigin;
@ViewColumn()
petPolicy!: PetPolicy;
@ViewColumn()
petOrigin!: ValueOrigin;
@ViewColumn()
petSizes!: PetSize[] | null;
@ViewColumn()
petConditions!: string | null;
@ViewColumn()
isSearchable!: boolean;
@ViewColumn()
isBrandStore!: boolean;
@ViewColumn()
isHomePick!: boolean;
/** 1: 큐레이션 추천 · 2: 관광공사 장소 · null: AI 후보 아님 */
@ViewColumn()
aiTier!: 1 | 2 | null;
}
@@ -0,0 +1,56 @@
import { Check, Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { SYNC_JOBS, sqlIn } from '../place.constants';
import type { SyncJob } from '../place.constants';
/**
* 동기화 실행 기록 — "언제 받은 데이터인가"에 답하고, 오퍼레이션별 하루 호출 한도를 관리한다.
* 키나 요청 URL 은 남기지 않는다(오류 문구도 키를 가린 뒤 저장).
*/
@Entity()
@Index('ix_place_sync_run_job_started_at', ['job', 'startedAt'])
@Check('ck_place_sync_run_job', sqlIn('job', SYNC_JOBS))
export class PlaceSyncRun {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
job!: SyncJob;
/** 범위 — 예: 법정동 시·도 '27', 행정동 목록 등 */
@Column({ type: 'text', nullable: true })
scope!: string | null;
@Column({ type: 'timestamptz' })
startedAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
finishedAt!: Date | null;
@Column({ type: 'integer', default: 0 })
fetched!: number;
@Column({ type: 'integer', default: 0 })
inserted!: number;
@Column({ type: 'integer', default: 0 })
updated!: number;
@Column({ type: 'integer', default: 0 })
hidden!: number;
@Column({ type: 'integer', default: 0 })
gone!: number;
/** 오퍼레이션별 호출 수 { detailIntro2: 412, … } */
@Column({ type: 'jsonb', default: () => `'{}'` })
calls!: Record<string, number>;
/** 오류 앞 20건(키는 가린 뒤 저장) */
@Column({ type: 'jsonb', default: () => `'[]'` })
errors!: string[];
/** 원본 기준 시점(상가정보 stdrYm 등) */
@Column({ type: 'text', nullable: true })
snapshot!: string | null;
}
@@ -0,0 +1,191 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import type { Relation } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import {
IMAGE_LICENSES,
PLACE_CATEGORIES,
PLACE_SOURCES,
PLACE_STATUSES,
PUBLIC_PET_POLICIES,
sqlIn,
} from '../place.constants';
import type {
ImageLicense,
PlaceCategory,
PlaceSource,
PlaceStatus,
PublicPetPolicy,
} from '../place.constants';
import { PlaceCuration } from './place-curation.entity';
/**
* 공공데이터 장소 — 출처 1건이 1행이다. 동기화 명령이 자동으로 갱신한다.
* 운영자가 확인한 값은 place_curation 에 따로 두며, 동기화는 그 테이블을 건드리지 않는다.
* 장소는 지우지 않는다(큐레이션·코스가 가리킬 수 있다) — 사라지면 status 를 바꾼다.
*/
@Entity()
@Unique('uq_place_source', ['source', 'sourceId'])
@Index('ix_place_lat_lng', ['lat', 'lng'])
@Index('ix_place_category', ['category'])
@Index('ix_place_status', ['status'])
@Check('ck_place_source', sqlIn('source', PLACE_SOURCES))
@Check('ck_place_category', sqlIn('category', PLACE_CATEGORIES))
@Check('ck_place_status', sqlIn('status', PLACE_STATUSES))
@Check(
'ck_place_public_pet_policy',
sqlIn('public_pet_policy', PUBLIC_PET_POLICIES, true),
)
@Check('ck_place_image_license', sqlIn('image_license', IMAGE_LICENSES, true))
@Check(
'ck_place_lat_lng',
`"lat" BETWEEN -90 AND 90 AND "lng" BETWEEN -180 AND 180`,
)
export class Place {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text' })
source!: PlaceSource;
/** 관광공사 contentid · 상가정보 bizesId — (source, sourceId) 가 동기화·큐레이션의 연결 키 */
@Column({ type: 'text' })
sourceId!: string;
@Column({ type: 'text' })
category!: PlaceCategory;
/** 원본 분류 코드(lclsSystm3 · indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 */
@Column({ type: 'text', nullable: true })
sourceCategory!: string | null;
/** 관광공사 관광타입(contenttypeid: 12 관광지·14 문화시설·39 음식점 …) — 소개정보 조회에 필요하다 */
@Column({ type: 'text', nullable: true })
sourceType!: string | null;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text', nullable: true })
branchName!: string | null;
@Column({ type: 'text', nullable: true })
addressRoad!: string | null;
@Column({ type: 'text', nullable: true })
addressJibun!: string | null;
/** WGS84. 좌표가 없는 원본은 적재하지 않는다(지도·거리 계산 불가) */
@Column({ type: 'double precision' })
lat!: number;
@Column({ type: 'double precision' })
lng!: number;
/** 법정동 시·도(2자리) — 관광공사 동기화 범위 */
@Column({ type: 'text', nullable: true })
ldongRegnCd!: string | null;
/** 법정동 시·군·구(3자리) */
@Column({ type: 'text', nullable: true })
ldongSignguCd!: string | null;
/** 행정동(상가정보 8자리) — 상가정보 동기화 범위 */
@Column({ type: 'text', nullable: true })
adongCd!: string | null;
@Column({ type: 'text', nullable: true })
phone!: string | null;
@Column({ type: 'text', nullable: true })
homepage!: string | null;
@Column({ type: 'text', nullable: true })
overview!: string | null;
/** 영업시간·쉬는날 원문(관광공사 유형별 필드) */
@Column({ type: 'text', nullable: true })
publicHoursText!: string | null;
@Column({ type: 'text', nullable: true })
publicRestText!: string | null;
/** 원문을 opening-hours 파서로 해석한 결과 — 동기화 때마다 다시 계산한다 */
@Column({ type: 'jsonb', nullable: true })
publicHours!: ParsedOpeningHours | null;
/** 관광공사 반려동물 동반 정보 원문. 없으면 '미확인'(불가 아님) */
@Column({ type: 'jsonb', nullable: true })
publicPet!: Record<string, string> | null;
/** publicPet 을 동기화 때 요약한 값(전구역 → allowed, 일부구역 → partial) */
@Column({ type: 'text', nullable: true })
publicPetPolicy!: PublicPetPolicy | null;
/** 유형별 부가 정보(입장료·주차 등) 원문 */
@Column({ type: 'jsonb', nullable: true })
publicExtra!: Record<string, string> | null;
@Column({ type: 'text', nullable: true })
imageUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageThumbUrl!: string | null;
@Column({ type: 'text', nullable: true })
imageLicense!: ImageLicense | null;
@Column({ type: 'text', default: 'active' })
status!: PlaceStatus;
/** 관광공사 modifiedtime */
@Column({ type: 'timestamptz', nullable: true })
sourceModifiedAt!: Date | null;
/** 상가정보 기준년월(stdrYm) */
@Column({ type: 'text', nullable: true })
sourceSnapshot!: string | null;
/** 마지막 동기화에서 원본에 있던 시각 */
@Column({ type: 'timestamptz', nullable: true })
lastSeenAt!: Date | null;
/**
* 상세(개요·영업시간 등)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다.
* 개발 계정 한도(오퍼레이션별 하루 1,000건) 때문에 초기 적재가 며칠에 나뉘므로, 이 값으로 이어서 받는다.
*/
@Column({ type: 'timestamptz', nullable: true })
detailSyncedAt!: Date | null;
/**
* 같은 가게를 다른 출처가 이미 가지고 있으면 그 장소의 id(상가정보 → 관광공사).
* 값이 있으면 검색·추천에 내보내지 않고, 상세로 들어오면 병합 대상 장소를 보여 준다. 동기화가 끝날 때마다 다시 계산한다.
*/
@Column({ type: 'uuid', nullable: true })
mergedIntoId!: string | null;
@ManyToOne(() => Place, { nullable: true })
@JoinColumn({ name: 'merged_into_id' })
mergedInto?: Relation<Place> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@OneToOne(() => PlaceCuration, (curation) => curation.place)
curation?: Relation<PlaceCuration> | null;
}
@@ -0,0 +1,44 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import {
EventDetailDto,
EventListResponseDto,
} from './dto/event-list-item.dto';
import { EventSearchQueryDto } from './dto/event-search-query.dto';
import { FestivalService } from './festival.service';
/**
* 축제·공연·행사 조회 — 로그인 없이 쓴다.
* 홈 '이번 주말 축제·행사'(기간), 장소 상세 '근처 축제·행사'(좌표+반경)에 쓴다.
*/
@ApiTags('Event')
@Controller('events')
export class FestivalController {
constructor(private readonly festivalService: FestivalService) {}
@Get()
@ApiOperation({
summary: '축제·공연·행사 목록 (커서 페이지네이션)',
description:
'기간(from~to)과 겹치는 행사를 준다. lat·lng 를 주면 반경 안을 거리순으로, 없으면 시작일순으로 준다. 60일 넘는 상시 프로그램은 뺀다.',
})
@ApiStandardResponse(EventListResponseDto, { description: '행사 목록' })
@ApiErrorResponse('VAL_001', '조회 조건 오류(날짜·기간·좌표·반경·분류·커서)')
search(@Query() query: EventSearchQueryDto) {
return this.festivalService.search(query);
}
@Get(':id')
@ApiOperation({ summary: '축제·공연·행사 상세' })
@ApiStandardResponse(EventDetailDto, { description: '행사 상세' })
@ApiErrorResponse('VAL_001', 'id 형식 오류(UUID 가 아님)')
@ApiErrorResponse('RES_001', '행사를 찾을 수 없음(없거나 숨김)')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.festivalService.findOne(id);
}
}
@@ -0,0 +1,178 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { addDaysIso, daysBetweenIso } from '@/shared/utils/iso-date';
import { kstDateString } from '@/shared/utils/kst';
import { EventDetailDto, EventListItemDto } from './dto/event-list-item.dto';
import {
EVENT_RADIUS_DEFAULT_M,
EVENT_RANGE_DEFAULT_DAYS,
EVENT_RANGE_MAX_DAYS,
EventSearchQueryDto,
} from './dto/event-search-query.dto';
import { FestivalEvent } from './entities/festival-event.entity';
import type { EventSource } from './place.constants';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM, sqlEventWithinMaxSpan } from './place.sql';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/** 출처 표기(행사 상세 하단·앱 정보 화면) */
const PROVIDER_LABEL: Record<EventSource, string> = {
tour: '한국관광공사',
kopis: '(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)',
kcisa: '한국문화정보원 문화포털',
dgfca: '대구문화예술진흥원',
};
/**
* 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'.
* 60일 넘는 상시 프로그램과 숨김 행사는 목록에서 뺀다.
*/
@Injectable()
export class FestivalService {
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
) {}
async search(
query: EventSearchQueryDto,
now = new Date(),
): Promise<PaginatedResult<EventListItemDto>> {
const today = kstDateString(now);
const from = query.from ?? today;
// 끝을 생략하면 시작일 + 기본 일수. 시작일이 없는 날짜("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 },
});
}
const near =
query.lat !== undefined && query.lng !== undefined
? {
lat: query.lat,
lng: query.lng,
radius: query.radius ?? EVENT_RADIUS_DEFAULT_M,
}
: null;
const cursor = query.cursor
? decodePlaceCursor(query.cursor, near ? 'number' : 'string')
: null;
if (query.cursor && !cursor) {
throw new BusinessException('VAL_001', {
context: { reason: '커서 형식 오류' },
});
}
const qb = this.events
.createQueryBuilder('e')
.where('e.status = :active', { active: 'active' })
.andWhere('e.end_date >= :from AND e.start_date <= :to', { from, to })
// 상시 프로그램 제외 — 종류별 기간 상한(전시는 길게)
.andWhere(sqlEventWithinMaxSpan('e'));
if (query.kind?.length) {
qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind });
}
// 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다
let sortKey = '(e.start_date)';
if (near) {
sortKey = sqlDistanceM(':lat', ':lng', 'e.lat', 'e.lng');
const box = boundingBox(near.lat, near.lng, near.radius);
qb.andWhere('e.lat BETWEEN :latMin AND :latMax', box)
.andWhere('e.lng BETWEEN :lngMin AND :lngMax', box)
.andWhere(`${sortKey} <= :radius`)
.setParameters({ lat: near.lat, lng: near.lng, radius: near.radius });
}
const total = await qb.clone().getCount();
if (cursor) {
const ck = near ? ':ck' : 'CAST(:ck AS date)';
qb.andWhere(`(${sortKey}, e.id) > (${ck}, :cid)`, {
ck: cursor.k,
cid: cursor.id,
});
}
const { entities, raw } = await qb
.addSelect(sortKey, 'sort_key')
.orderBy('sort_key', 'ASC')
.addOrderBy('e.id', 'ASC')
.limit(query.limit + 1)
.getRawAndEntities<{ sort_key: number | string }>();
const hasNext = entities.length > query.limit;
const page = entities.slice(0, query.limit);
const distanceAt = (i: number) => (near ? Number(raw[i]?.sort_key) : null);
const items = page.map((e, i) => this.toListItem(e, distanceAt(i), today));
const last = page.at(-1);
const nextCursor =
hasNext && last
? encodePlaceCursor({
k: near ? (distanceAt(page.length - 1) ?? 0) : last.startDate,
id: last.id,
})
: null;
return toPaginatedResult(items, { hasNext, nextCursor, total });
}
async findOne(id: string, now = new Date()): Promise<EventDetailDto> {
const event = await this.events.findOneBy({ id });
if (!event || event.status !== 'active') {
throw new BusinessException('RES_001', { context: { eventId: id } });
}
return {
...this.toListItem(event, null, kstDateString(now)),
overview: event.overview,
playtimeText: event.playtimeText,
homepage: event.homepage,
phone: event.phone,
bookingUrl: event.bookingUrl,
imageUrl: event.imageUrl,
attribution: {
provider: PROVIDER_LABEL[event.source],
asOf: event.sourceModifiedAt
? new Date(event.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
.slice(0, 10)
: null,
},
};
}
private toListItem(
e: FestivalEvent,
distance: number | null,
today: string,
): EventListItemDto {
return {
id: e.id,
name: e.name,
kind: e.kind,
genre: e.genre,
startDate: e.startDate,
endDate: e.endDate,
ongoing: e.startDate <= today && today <= e.endDate,
address: e.address,
eventPlace: e.eventPlace,
lat: e.lat,
lng: e.lng,
distanceM:
distance === null || !Number.isFinite(distance)
? null
: Math.round(distance),
feeText: e.feeText,
imageThumbUrl: e.imageThumbUrl,
imageLicense: e.imageLicense,
};
}
}
@@ -0,0 +1,28 @@
import { publicPetNote } from './pet-note';
describe('publicPetNote', () => {
it('글머리표·줄바꿈을 정리해 한 줄로 잇는다(김광석다시그리기길 실제 원문)', () => {
expect(
publicPetNote({
acmpyTypeCd: '전구역 동반가능',
acmpyPsblCpam: '전 견종 동반 가능',
acmpyNeedMtr: '목줄 착용',
etcAcmpyInfo:
'- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참 및 배변처리 필수',
}),
).toBe(
'전 견종 동반 가능 · 목줄 착용 · 맹견의 경우, 입마개 착용 필수 · 배변봉투 지참 및 배변처리 필수',
);
});
it('같은 문장이 여러 필드에 반복되면 한 번만 쓴다', () => {
expect(
publicPetNote({ acmpyNeedMtr: '목줄 착용', etcAcmpyInfo: '※ 목줄 착용' }),
).toBe('목줄 착용');
});
it('조건 문장이 없으면 null', () => {
expect(publicPetNote({ acmpyTypeCd: '전구역 동반가능' })).toBeNull();
expect(publicPetNote(null)).toBeNull();
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 관광공사 반려동물 동반 정보 원문 → 화면에 보여 줄 조건 문장 (순수 함수).
* 원문에는 글머리표('- ')와 줄바꿈이 섞여 온다(예: "- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참").
*/
/** 조건으로 보여 줄 필드(동반 구분 acmpyTypeCd 는 petPolicy 로 따로 나간다) */
const NOTE_FIELDS = ['acmpyPsblCpam', 'acmpyNeedMtr', 'etcAcmpyInfo'];
/** 줄 앞의 글머리표·번호 표시 */
const BULLET_RE = /^\s*(?:[-*•·※]|\d+[.)])\s*/;
export function publicPetNote(
raw: Record<string, string> | null,
): string | null {
if (!raw) return null;
const parts: string[] = [];
for (const key of NOTE_FIELDS) {
const value = raw[key];
if (typeof value !== 'string') continue;
for (const line of value.split(/\n+/)) {
const text = line.replace(BULLET_RE, '').trim();
if (text !== '' && !parts.includes(text)) parts.push(text);
}
}
return parts.length > 0 ? parts.join(' · ') : null;
}
@@ -0,0 +1,36 @@
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
describe('place cursor', () => {
it('거리·이름 커서를 인코딩했다가 그대로 되돌린다', () => {
const byDistance = encodePlaceCursor({ k: 812.4567, id: ID });
expect(decodePlaceCursor(byDistance, 'number')).toEqual({
k: 812.4567,
id: ID,
});
const byName = encodePlaceCursor({ k: '김광석다시그리기길', id: ID });
expect(decodePlaceCursor(byName, 'string')).toEqual({
k: '김광석다시그리기길',
id: ID,
});
});
it('정렬 방식과 맞지 않는 커서는 받지 않는다', () => {
const byName = encodePlaceCursor({ k: '가', id: ID });
expect(decodePlaceCursor(byName, 'number')).toBeNull();
});
it('형식이 틀린 커서는 받지 않는다', () => {
expect(decodePlaceCursor('아무거나', 'number')).toBeNull();
expect(
decodePlaceCursor(
Buffer.from('{"k":1,"id":"x"}').toString('base64url'),
'number',
),
).toBeNull();
expect(
decodePlaceCursor(Buffer.from('null').toString('base64url'), 'number'),
).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* 장소 목록 커서 (순수 함수) — 정렬 기준값과 id 로 다음 페이지 시작점을 가리킨다(키셋 페이지네이션).
* 거리순이면 k 는 거리(m, 숫자), 이름순이면 k 는 이름(문자열)이다.
*/
export interface PlaceCursor {
k: number | string;
id: string;
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function encodePlaceCursor(cursor: PlaceCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}
/**
* 커서를 해석한다. 형식이 틀리거나 정렬 방식과 맞지 않으면(거리순인데 문자열 등) null.
* null 이면 호출하는 쪽이 VAL_001 로 응답한다.
*/
export function decodePlaceCursor(
raw: string,
kind: 'number' | 'string',
): PlaceCursor | null {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object') return null;
const { k, id } = parsed as Record<string, unknown>;
if (typeof id !== 'string' || !UUID_RE.test(id)) return null;
if (kind === 'number' && typeof k === 'number' && Number.isFinite(k)) {
return { k, id };
}
if (kind === 'string' && typeof k === 'string') return { k, id };
return null;
}
@@ -0,0 +1,35 @@
import {
AI_TIER2_CATEGORIES,
PLACE_CATEGORIES,
sqlIn,
sqlValues,
} from './place.constants';
describe('place.constants', () => {
it('CHECK 제약 식을 코드값 목록으로 만든다', () => {
expect(sqlIn('status', ['active', 'gone'])).toBe(
`"status" IN ('active', 'gone')`,
);
expect(sqlIn('setting', ['indoor'], true)).toBe(
`"setting" IS NULL OR "setting" IN ('indoor')`,
);
});
it('따옴표 같은 문자가 섞인 코드값은 SQL 로 만들지 않고 실패한다', () => {
expect(() => sqlValues(["a'; DROP TABLE place; --"])).toThrow();
});
it('언어 코드처럼 하이픈이 든 값은 허용한다', () => {
expect(sqlValues(['zh-Hans'])).toBe(`'zh-Hans'`);
});
it('숙박·쇼핑은 큐레이션 없이 AI 후보가 되지 않는다', () => {
const tier2: readonly string[] = AI_TIER2_CATEGORIES;
expect(tier2).not.toContain('lodging');
expect(tier2).not.toContain('shopping');
expect(tier2).not.toContain('bar');
for (const c of tier2) {
expect(PLACE_CATEGORIES).toContain(c);
}
});
});
@@ -0,0 +1,202 @@
/**
* 장소 DB 의 코드값 — 엔티티 CHECK 제약·place_listing 뷰·서비스가 이 목록 하나를 공유한다.
* 설계: docs/design/place-db.md
*
* ⚠️ 목록을 바꾸면 CHECK 제약도 바뀌는데, migration:generate 는 이름이 같은 CHECK 제약의 식 변경을
* 감지하지 못한다. 새 마이그레이션에 DROP CONSTRAINT / ADD CONSTRAINT 를 직접 적는다
* (예: 1793599990257-AddPlaceSourceTypeAndDetailSync). 뷰 식 변경은 generate 가 감지한다.
*/
/** 데이터 출처: 관광공사(TourAPI) · 소상공인 상가정보 */
export const PLACE_SOURCES = ['tour', 'store'] as const;
export type PlaceSource = (typeof PLACE_SOURCES)[number];
/**
* 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6, 대응표는 sync/tour-mapper.ts).
* other: 교육시설·연회장처럼 데이트 장소가 아닌 곳 — 검색 '전체'에만 나오고 AI 후보가 아니다.
*/
export const PLACE_CATEGORIES = [
'nature',
'attraction',
'culture',
'leisure',
'food',
'cafe',
'bar',
'shopping',
'lodging',
'other',
] as const;
export type PlaceCategory = (typeof PLACE_CATEGORIES)[number];
/** active: 노출 · hidden: 관광공사 비표출 · gone: 스냅샷에서 사라짐(폐업 추정). 장소는 지우지 않는다 */
export const PLACE_STATUSES = ['active', 'hidden', 'gone'] as const;
export type PlaceStatus = (typeof PLACE_STATUSES)[number];
/** 운영자 판단: 데이트 추천 O · X · 아직 안 봄 */
export const CURATION_VERDICTS = [
'recommend',
'not_recommend',
'pending',
] as const;
export type CurationVerdict = (typeof CURATION_VERDICTS)[number];
/** 반려동물 동반 — 정보가 없으면 '불가'가 아니라 'unknown' 이다 */
export const PET_POLICIES = [
'allowed',
'partial',
'not_allowed',
'unknown',
] as const;
export type PetPolicy = (typeof PET_POLICIES)[number];
/** 관광공사 반려동물 정보는 "동반 가능한 곳"만 담으므로 두 값뿐이다 */
export const PUBLIC_PET_POLICIES = ['allowed', 'partial'] as const;
export type PublicPetPolicy = (typeof PUBLIC_PET_POLICIES)[number];
export const PET_SIZES = ['small', 'medium', 'large'] as const;
export type PetSize = (typeof PET_SIZES)[number];
export const PLACE_SETTINGS = ['indoor', 'outdoor', 'both'] as const;
export type PlaceSetting = (typeof PLACE_SETTINGS)[number];
/** 확인 근거 — 업체 본인 채널·전화·방문만 인정한다(카카오·네이버 정보 금지) */
export const VERIFY_METHODS = ['phone', 'official_channel', 'visit'] as const;
export type VerifyMethod = (typeof VERIFY_METHODS)[number];
/** 한국어는 place 본체에 두고, 나머지 언어만 place_i18n 에 둔다 */
export const I18N_LANGS = ['en', 'ja', 'zh-Hans', 'zh-Hant'] as const;
export type I18nLang = (typeof I18N_LANGS)[number];
export const I18N_ORIGINS = ['tour_en', 'machine', 'curator'] as const;
export type I18nOrigin = (typeof I18N_ORIGINS)[number];
/** 공공누리 유형 — 화면에 출처 표시가 필요하다 */
export const IMAGE_LICENSES = ['Type1', 'Type3'] as const;
export type ImageLicense = (typeof IMAGE_LICENSES)[number];
/** 행사 종류 — 관광공사 lclsSystm2(EV01 축제 · EV02 공연 · EV03 행사) + 전시(KOPIS 장르는 genre 에 따로 둔다) */
export const EVENT_KINDS = [
'festival',
'performance',
'event',
'exhibition',
] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
export const EVENT_STATUSES = ['active', 'hidden'] as const;
/**
* 행사 출처 — tour 한국관광공사 · kopis 공연예술통합전산망 · kcisa 한국문화정보원 · dgfca 대구문화예술진흥원.
* (출처, 원본 id) 가 행사·공연장의 연결 키다.
*/
export const EVENT_SOURCES = ['tour', 'kopis', 'kcisa', 'dgfca'] as const;
export type EventSource = (typeof EVENT_SOURCES)[number];
/**
* 이보다 긴 행사는 상시 프로그램으로 보고 목록에서 뺀다(2026-10-29 합의는 60일).
* 전시는 몇 달씩 하는 것이 보통이라 길게 잡는다 — 그대로 두면 전시 대부분이 빠진다.
*/
export const EVENT_MAX_SPAN_DAYS: Record<EventKind, number> = {
festival: 60,
performance: 60,
event: 60,
exhibition: 183,
};
export type EventStatus = (typeof EVENT_STATUSES)[number];
export const SYNC_JOBS = [
'tour_kor',
'tour_eng',
'tour_pet',
'store',
'event',
'closure',
'kopis',
'kcisa',
] as const;
export type SyncJob = (typeof SYNC_JOBS)[number];
/**
* AI 2순위 후보가 되는 분류(큐레이션 없이도 관광공사 장소면 후보).
* 숙박·쇼핑은 큐레이션으로 추천(O) 받았을 때만 후보가 된다 — 2026-11-02 합의.
*/
export const AI_TIER2_CATEGORIES = [
'nature',
'attraction',
'culture',
'leisure',
'food',
'cafe',
] as const satisfies readonly PlaceCategory[];
/**
* 사후면세점(관광공사 SH040300) — 2026-11-02 대구 도심 쇼핑 135곳 대부분이 더현대 등에 입점한 브랜드 매장이었다.
* 목록·필터 결과에는 내보내지 않고 이름 검색(q)에만 나온다. 백화점·쇼핑몰 자체는 예외(아래 이름으로 시작).
*/
export const BRAND_STORE_CATEGORY = 'SH040300';
export const MALL_NAME_PREFIXES = [
'현대백화점',
'더현대',
'롯데백화점',
'대구백화점',
'신세계',
'대구신세계',
'동아백화점',
] as const;
/**
* 원본 좌표가 실제 위치가 아닌 관광공사 장소(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곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
*/
export const MERGE_TARGET_CATEGORIES = [
'cafe',
'food',
'bar',
] as const satisfies readonly PlaceCategory[];
/** 같은 가게로 볼 최대 거리(m) */
export const MERGE_MAX_DISTANCE_M = 100;
/** 정규식 대안 목록 `a|b|c` — 한글·영문·숫자 단어만 허용한다(뷰 SQL 에 들어간다) */
export function sqlAlternation(words: readonly string[]): string {
for (const w of words) {
if (!/^[가-힣A-Za-z0-9]+$/.test(w)) {
throw new Error(`SQL 정규식 단어에 허용되지 않는 문자가 있습니다: ${w}`);
}
}
return words.join('|');
}
/**
* CHECK 제약·뷰에 넣을 SQL 값 목록 — `'a', 'b'`.
* 코드 상수로만 만들지만, 혹시라도 따옴표가 섞이면 SQL 이 깨지므로 즉시 실패시킨다.
*/
export function sqlValues(values: readonly string[]): string {
for (const v of values) {
if (!/^[A-Za-z0-9_-]+$/.test(v)) {
throw new Error(`SQL 코드값에 허용되지 않는 문자가 있습니다: ${v}`);
}
}
return values.map((v) => `'${v}'`).join(', ');
}
/** `"col" IN (...)` CHECK 식. nullable 이면 NULL 도 허용한다 */
export function sqlIn(
column: string,
values: readonly string[],
nullable = false,
): string {
const inList = `"${column}" IN (${sqlValues(values)})`;
return nullable ? `"${column}" IS NULL OR ${inList}` : inList;
}
@@ -0,0 +1,46 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PlaceDetailDto } from './dto/place-detail.dto';
import { PlaceListResponseDto } from './dto/place-list-item.dto';
import { PlaceSearchQueryDto } from './dto/place-search-query.dto';
import { PlaceService } from './place.service';
/**
* 장소 조회 — 로그인 없이 쓴다(둘러보기·검색은 비로그인 허용, 2026-10-28 합의).
* 라우팅과 입출력만 담당하고 조회 규칙은 PlaceService·place_listing 뷰가 정한다.
*/
@ApiTags('Place')
@Controller('places')
export class PlaceController {
constructor(private readonly placeService: PlaceService) {}
@Get()
@ApiOperation({
summary: '장소 검색·근처 목록 (커서 페이지네이션)',
description:
'lat·lng 를 주면 반경 안을 거리순으로, 없으면 이름순으로 준다. 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.',
})
@ApiStandardResponse(PlaceListResponseDto, { description: '장소 목록' })
@ApiErrorResponse('VAL_001', '검색 조건 오류(좌표·반경·분류·커서 형식)')
search(@Query() query: PlaceSearchQueryDto) {
return this.placeService.search(query);
}
@Get(':id')
@ApiOperation({
summary: '장소 상세',
description:
'같은 가게로 병합된 장소 id 로 들어오면 병합 대상 장소를 준다. 숨김·폐업 추정 장소는 찾을 수 없음으로 응답한다.',
})
@ApiStandardResponse(PlaceDetailDto, { description: '장소 상세' })
@ApiErrorResponse('VAL_001', 'id 형식 오류(UUID 가 아님)')
@ApiErrorResponse('RES_001', '장소를 찾을 수 없음')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.placeService.findOne(id);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventVenue } from './entities/event-venue.entity';
import { FestivalEvent } from './entities/festival-event.entity';
import { PlaceCuration } from './entities/place-curation.entity';
import { Place } from './entities/place.entity';
import { PlaceI18n } from './entities/place-i18n.entity';
import { PlaceListing } from './entities/place-listing.entity';
import { PlaceSyncRun } from './entities/place-sync-run.entity';
import { FestivalController } from './festival.controller';
import { FestivalService } from './festival.service';
import { PlaceController } from './place.controller';
import { PlaceService } from './place.service';
/**
* 장소·축제 데이터 모듈 (설계: docs/design/place-db.md).
* 조회 API(장소 검색·근처 목록·상세, 축제·공연·행사 목록·상세)를 제공한다. 동기화는 CLI 전용 PlaceSyncModule(sync/)이 맡는다.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
Place,
PlaceCuration,
PlaceI18n,
PlaceListing,
FestivalEvent,
EventVenue,
PlaceSyncRun,
]),
],
controllers: [PlaceController, FestivalController],
providers: [PlaceService, FestivalService],
exports: [TypeOrmModule, PlaceService, FestivalService],
})
export class PlaceModule {}
+247
View File
@@ -0,0 +1,247 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { kstClock } from '@/shared/utils/kst';
import { isOpenAt } from '@/shared/utils/opening-hours';
import { escapeLike } from '@/shared/utils/sql-like';
import { PlaceDetailDto } from './dto/place-detail.dto';
import { PlaceListItemDto } from './dto/place-list-item.dto';
import {
PLACE_RADIUS_DEFAULT_M,
PlaceSearchQueryDto,
} from './dto/place-search-query.dto';
import { PlaceListing } from './entities/place-listing.entity';
import { Place } from './entities/place.entity';
import { publicPetNote } from './pet-note';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
/** 출처 표기 */
const PROVIDER_LABEL: Record<Place['source'], string> = {
tour: '한국관광공사',
store: '소상공인시장진흥공단 상가(상권)정보',
};
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* 장소 조회 — 검색·근처 목록과 상세. 노출 규칙·값 우선순위는 place_listing 뷰가 정하고, 여기서는 뷰를 읽기만 한다.
*/
@Injectable()
export class PlaceService {
constructor(
@InjectRepository(PlaceListing)
private readonly listings: Repository<PlaceListing>,
@InjectRepository(Place) private readonly places: Repository<Place>,
) {}
/**
* 검색·근처 목록. 좌표가 있으면 반경 안을 거리순으로, 없으면 이름순으로 준다(키셋 커서).
* 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.
*/
async search(
query: PlaceSearchQueryDto,
now = new Date(),
): Promise<PaginatedResult<PlaceListItemDto>> {
const near =
query.lat !== undefined && query.lng !== undefined
? {
lat: query.lat,
lng: query.lng,
radius: query.radius ?? PLACE_RADIUS_DEFAULT_M,
}
: null;
const cursor = query.cursor
? decodePlaceCursor(query.cursor, near ? 'number' : 'string')
: null;
if (query.cursor && !cursor) {
throw new BusinessException('VAL_001', {
context: { reason: '커서 형식 오류' },
});
}
const qb = this.listings
.createQueryBuilder('v')
.where('v.is_searchable = true');
if (query.q) {
qb.andWhere('v.name ILIKE :q', { q: `%${escapeLike(query.q)}%` });
} else {
qb.andWhere('v.is_brand_store = false');
}
if (query.category?.length) {
qb.andWhere('v.category IN (:...categories)', {
categories: query.category,
});
}
if (query.pet) {
qb.andWhere('v.pet_policy IN (:...pets)', {
pets: ['allowed', 'partial'],
});
}
if (query.indoor) {
qb.andWhere('v.setting IN (:...settings)', {
settings: ['indoor', 'both'],
});
}
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)';
if (near) {
sortKey = sqlDistanceM(':lat', ':lng', 'v.lat', 'v.lng');
const box = boundingBox(near.lat, near.lng, near.radius);
qb.andWhere('v.lat BETWEEN :latMin AND :latMax', box)
.andWhere('v.lng BETWEEN :lngMin AND :lngMax', box)
.andWhere(`${sortKey} <= :radius`)
.setParameters({ lat: near.lat, lng: near.lng, radius: near.radius });
}
const total = await qb.clone().getCount();
if (cursor) {
qb.andWhere(`(${sortKey}, v.id) > (:ck, :cid)`, {
ck: cursor.k,
cid: cursor.id,
});
}
const { entities, raw } = await qb
.addSelect(sortKey, 'sort_key')
.orderBy('sort_key', 'ASC')
.addOrderBy('v.id', 'ASC')
.limit(query.limit + 1)
.getRawAndEntities<{ sort_key: number | string }>();
const hasNext = entities.length > query.limit;
const page = entities.slice(0, query.limit);
const clock = kstClock(now);
const distanceAt = (i: number) => (near ? Number(raw[i]?.sort_key) : null);
const items = page.map((v, i) => this.toListItem(v, distanceAt(i), clock));
const last = page.at(-1);
const nextCursor =
hasNext && last
? encodePlaceCursor({
k: near ? (distanceAt(page.length - 1) ?? 0) : last.name,
id: last.id,
})
: null;
return toPaginatedResult(items, { hasNext, nextCursor, total });
}
/**
* 상세. 다른 장소로 병합된 id(같은 가게의 상가정보 쪽)로 들어오면 병합 대상 장소를 보여 준다.
* 없거나 숨김·폐업 추정이면 RES_001.
*/
async findOne(id: string, now = new Date()): Promise<PlaceDetailDto> {
const first = await this.places.findOne({
where: { id },
relations: { curation: true },
});
const place =
first?.mergedIntoId != null
? await this.places.findOne({
where: { id: first.mergedIntoId },
relations: { curation: true },
})
: first;
const listing = place
? await this.listings.findOneBy({ id: place.id })
: null;
if (!place || !listing?.isSearchable) {
throw new BusinessException('RES_001', { context: { placeId: id } });
}
const curation = place.curation ?? null;
const curatedHours = listing.hoursOrigin === 'curation';
const hoursStatus = curatedHours
? curation?.hours?.status
: place.publicHours?.status;
return {
...this.toListItem(listing, null, kstClock(now)),
addressJibun: place.addressJibun,
phone: place.phone,
homepage: place.homepage,
overview: place.overview,
imageUrl: place.imageUrl,
hours: {
text: curatedHours
? (curation?.hoursText ?? null)
: place.publicHoursText,
restText: curatedHours
? (curation?.restText ?? null)
: place.publicRestText,
status: hoursStatus ?? 'unknown',
},
pet: {
sizes: listing.petOrigin === 'curation' ? listing.petSizes : null,
conditions:
listing.petOrigin === 'curation'
? (curation?.petConditions ?? null)
: listing.petOrigin === 'public'
? publicPetNote(place.publicPet)
: null,
},
extra: place.publicExtra,
curation: listing.isHomePick
? {
reason: curation?.reasonKo ?? null,
officialUrl: curation?.officialUrl ?? null,
}
: null,
attribution: {
provider: PROVIDER_LABEL[place.source],
asOf:
place.source === 'tour'
? place.sourceModifiedAt
? new Date(place.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
.slice(0, 10)
: null
: place.sourceSnapshot,
},
};
}
private toListItem(
v: PlaceListing,
distance: number | null,
clock: { date: string; minutes: number },
): PlaceListItemDto {
return {
id: v.id,
name: v.name,
branchName: v.branchName,
category: v.category,
source: v.source,
address: v.addressRoad,
lat: v.lat,
lng: v.lng,
distanceM:
distance === null || !Number.isFinite(distance)
? null
: Math.round(distance),
imageThumbUrl: v.imageThumbUrl,
imageLicense: v.imageLicense,
hoursOrigin: v.hoursOrigin,
openNow: v.hours ? isOpenAt(v.hours, clock.date, clock.minutes) : null,
petPolicy: v.petPolicy,
petOrigin: v.petOrigin,
setting: v.setting,
verifiedAt: v.verifiedAt,
};
}
}
@@ -0,0 +1,13 @@
import { boundingBox } from './place.sql';
describe('boundingBox', () => {
it('반경을 담는 위도·경도 범위를 만든다(대구 위도에서 경도 폭이 더 넓다)', () => {
const box = boundingBox(35.865, 128.5966, 1500);
const dLat = box.latMax - box.latMin;
const dLng = box.lngMax - box.lngMin;
expect(dLat).toBeCloseTo((2 * 1500) / 111_320, 6);
expect(dLng).toBeGreaterThan(dLat);
expect(box.latMin).toBeLessThan(35.865);
expect(box.lngMax).toBeGreaterThan(128.5966);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다.
*/
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
/** 위도 1도의 거리(m) */
export const METERS_PER_LAT_DEGREE = 111_320;
/** 두 좌표 사이 거리(m) — 하버사인. 부동소수점 오차로 asin 범위를 넘지 않게 1 로 자른다 */
export function sqlDistanceM(
lat1: string,
lng1: string,
lat2: string,
lng2: string,
): string {
return (
`(2 * 6371000 * asin(LEAST(1, sqrt(` +
`power(sin(radians(${lat2} - ${lat1}) / 2), 2) + ` +
`cos(radians(${lat1})) * cos(radians(${lat2})) * power(sin(radians(${lng2} - ${lng1}) / 2), 2)` +
`))))`
);
}
/**
* 반경 검색 1차 거르기용 사각형(위도·경도 범위). 인덱스(lat, lng)를 타게 한 뒤 정확한 거리로 다시 거른다.
*/
export function boundingBox(
lat: number,
lng: number,
radiusM: number,
): { latMin: number; latMax: number; lngMin: number; lngMax: number } {
const dLat = radiusM / METERS_PER_LAT_DEGREE;
const dLng =
radiusM /
(METERS_PER_LAT_DEGREE * Math.max(Math.cos((lat * Math.PI) / 180), 0.01));
return {
latMin: lat - dLat,
latMax: lat + dLat,
lngMin: lng - dLng,
lngMax: lng + dLng,
};
}
/**
* 종류별 기간 상한 안의 행사인가(상시 프로그램 제외) — 상수(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,38 @@
import { isSameShopName, normalizeShopName } from './shop-name';
// 2026-11-02 대구 적재 데이터에서 관광공사·상가정보가 겹친 실제 사례
describe('isSameShopName', () => {
it('띄어쓰기·괄호·영문 병기가 달라도 같은 가게로 본다', () => {
expect(isSameShopName('커피명가 라핀카', '커피명가라핀카')).toBe(true);
expect(isSameShopName('아눅 앞산', '아눅앞산')).toBe(true);
expect(
isSameShopName(
'낫온리커피어반델리(NOT ONLY COFFEE URBAN DELI)',
'낫온리커피어반델리',
),
).toBe(true);
});
it('한쪽이 다른 쪽을 포함하면(두 글자 이상) 같은 가게로 본다', () => {
expect(isSameShopName('카페 접속', '접속')).toBe(true);
expect(isSameShopName('롤러커피', '롤러커피롤러커피플러스')).toBe(true);
expect(isSameShopName('원조아삼겹 본점', '원조아삼겹')).toBe(true);
});
it('한 글자 상호는 완전히 같을 때만 같은 가게다', () => {
expect(isSameShopName('류', '류')).toBe(true);
expect(isSameShopName('올리브영 대구동성로광장점', '동')).toBe(false);
expect(isSameShopName('정호승 문학관', '문')).toBe(false);
});
it('이름이 전혀 다르면 다른 가게다', () => {
expect(isSameShopName('김진수로스터리카페', '오마모리')).toBe(false);
expect(isSameShopName('', '오마모리')).toBe(false);
});
});
describe('normalizeShopName', () => {
it('소문자 한글·영문·숫자만 남긴다', () => {
expect(normalizeShopName('Cafe 1979 (교동)')).toBe('cafe1979교동');
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 상호 비교 (순수 함수) — 같은 가게를 관광공사·상가정보가 둘 다 가지고 있는지 판단할 때 쓴다.
*/
/** 비교용 정규화: 소문자로, 한글·영문·숫자만 남긴다(공백·괄호·기호 제거) */
export function normalizeShopName(name: string): string {
return name.toLowerCase().replace(/[^0-9a-z가-힣]/g, '');
}
/** 한쪽이 다른 쪽을 포함할 때, 짧은 쪽이 이 글자 수 이상이어야 같은 가게로 본다(한 글자 상호 '동'·'문' 오탐 방지) */
const MIN_CONTAINED_LENGTH = 2;
/**
* 두 상호가 같은 가게로 보이는지.
* - 정규화한 이름이 같으면 같다 ('커피명가 라핀카' = '커피명가라핀카', '류' = '류')
* - 한쪽이 다른 쪽을 포함하고 짧은 쪽이 2글자 이상이면 같다 ('카페 접속' ⊃ '접속', '롤러커피롤러커피플러스' ⊃ '롤러커피')
* 거리·분류 조건은 호출하는 쪽에서 따로 건다(이름만으로는 '이디야커피 국립대구박물관점' ⊃ '국립대구박물관'도 같다고 나온다).
*/
export function isSameShopName(a: string, b: string): boolean {
const x = normalizeShopName(a);
const y = normalizeShopName(b);
if (x === '' || y === '') return false;
if (x === y) return true;
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= MIN_CONTAINED_LENGTH && long.includes(short);
}
@@ -0,0 +1,34 @@
import { BudgetExceededError, CallBudget, sumCalls } from './call-budget';
describe('CallBudget', () => {
it('오늘 이미 쓴 수를 빼고 남은 호출 수를 센다', () => {
const budget = new CallBudget(10, { detailIntro2: 7 });
expect(budget.remaining('detailIntro2')).toBe(3);
expect(budget.remaining('detailCommon2')).toBe(10);
});
it('한도를 넘기면 BudgetExceededError 를 던지고 수를 늘리지 않는다', () => {
const budget = new CallBudget(2);
budget.take('detailCommon2');
budget.take('detailCommon2');
expect(() => budget.take('detailCommon2')).toThrow(BudgetExceededError);
expect(budget.spent()).toEqual({ detailCommon2: 2 });
});
it('이번 실행에서 쓴 수만 따로 돌려준다(이전 실행분 제외)', () => {
const budget = new CallBudget(100, { areaBasedList2: 5 });
budget.take('areaBasedList2');
expect(budget.spent()).toEqual({ areaBasedList2: 1 });
});
});
describe('sumCalls', () => {
it('여러 실행 기록의 오퍼레이션별 호출 수를 합친다', () => {
expect(
sumCalls([
{ detailIntro2: 400, areaBasedList2: 2 },
{ detailIntro2: 300 },
]),
).toEqual({ detailIntro2: 700, areaBasedList2: 2 });
});
});
@@ -0,0 +1,70 @@
/**
* 공공데이터포털 API 하루 호출 한도 관리. 관광공사 개발 계정은 **오퍼레이션마다** 하루 1,000건, 상가정보는 10,000건이다.
* 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다.
*/
/** 한도에 닿았을 때 던진다. 동기화는 이 오류를 받으면 정상 종료한다 */
export class BudgetExceededError extends Error {
constructor(
readonly operation: string,
readonly reason: 'local' | 'server',
) {
super(
reason === 'local'
? `공공데이터 ${operation} 오늘 호출 한도에 닿았습니다`
: `공공데이터 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
);
this.name = 'BudgetExceededError';
}
}
/** 오퍼레이션별 호출 수를 센다. 오늘 이전 실행에서 쓴 수부터 시작한다 */
export class CallBudget {
private readonly used = new Map<string, number>();
private readonly spentThisRun = new Map<string, number>();
constructor(
private readonly limit: number,
usedToday: Record<string, number> = {},
) {
for (const [op, n] of Object.entries(usedToday)) this.used.set(op, n);
}
remaining(operation: string): number {
return Math.max(0, this.limit - (this.used.get(operation) ?? 0));
}
canSpend(operation: string, count = 1): boolean {
return this.remaining(operation) >= count;
}
/** 호출 1회를 쓴다. 남은 한도가 없으면 BudgetExceededError */
take(operation: string): void {
if (!this.canSpend(operation)) {
throw new BudgetExceededError(operation, 'local');
}
this.used.set(operation, (this.used.get(operation) ?? 0) + 1);
this.spentThisRun.set(
operation,
(this.spentThisRun.get(operation) ?? 0) + 1,
);
}
/** 이번 실행에서 쓴 호출 수 — place_sync_run.calls 에 남긴다 */
spent(): Record<string, number> {
return Object.fromEntries(this.spentThisRun);
}
}
/** 여러 실행 기록의 오퍼레이션별 호출 수를 합친다 */
export function sumCalls(
records: readonly Record<string, number>[],
): Record<string, number> {
const total: Record<string, number> = {};
for (const rec of records) {
for (const [op, n] of Object.entries(rec)) {
if (Number.isFinite(n)) total[op] = (total[op] ?? 0) + n;
}
}
return total;
}
@@ -0,0 +1,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, '')
);
}
@@ -0,0 +1,114 @@
import {
festivalDetail,
festivalItemToEvent,
toIsoDate,
} from './festival-mapper';
// 2026-10-29 대구 점검에서 본 수성빛예술제를 줄인 것
const item = (over: Record<string, unknown> = {}) => ({
contentid: '2785467',
contenttypeid: '15',
title: '수성빛예술제',
eventstartdate: '20261127',
eventenddate: '20261206',
addr1: '대구광역시 수성구 무학로 42',
addr2: '(두산동)',
mapx: '128.6177',
mapy: '35.8277',
lDongRegnCd: '27',
lDongSignguCd: '260',
lclsSystm1: 'EV',
lclsSystm2: 'EV01',
lclsSystm3: 'EV010200',
cpyrhtDivCd: 'Type3',
firstimage: 'http://tong.visitkorea.or.kr/f.jpg',
firstimage2: 'http://tong.visitkorea.or.kr/f_s.jpg',
tel: '053-666-4842',
modifiedtime: '20261020101500',
...over,
});
describe('toIsoDate', () => {
it("'YYYYMMDD' 를 'YYYY-MM-DD' 로 바꾸고, 없는 날짜는 null", () => {
expect(toIsoDate('20261127')).toBe('2026-11-27');
expect(toIsoDate('20260230')).toBeNull();
expect(toIsoDate('2026-11-27')).toBeNull();
expect(toIsoDate(null)).toBeNull();
});
});
describe('festivalItemToEvent', () => {
it('목록 항목을 행 컬럼으로 바꾼다(EV01 → festival)', () => {
expect(festivalItemToEvent(item())).toEqual({
sourceId: '2785467',
kind: 'festival',
sourceCategory: 'EV010200',
name: '수성빛예술제',
startDate: '2026-11-27',
endDate: '2026-12-06',
lat: 35.8277,
lng: 128.6177,
address: '대구광역시 수성구 무학로 42 (두산동)',
ldongRegnCd: '27',
ldongSignguCd: '260',
phone: '053-666-4842',
// 관광공사 사진 서버는 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'),
});
});
it('공연·행사 분류와, 분류가 비면 행사로 본다', () => {
expect(festivalItemToEvent(item({ lclsSystm2: 'EV02' }))).toMatchObject({
kind: 'performance',
});
expect(
festivalItemToEvent(item({ lclsSystm2: '', lclsSystm3: '' })),
).toMatchObject({ kind: 'event', sourceCategory: null });
});
it('좌표가 없어도 행사는 남기고 좌표만 비운다', () => {
expect(festivalItemToEvent(item({ mapx: '', mapy: '' }))).toMatchObject({
lat: null,
lng: null,
});
});
it('날짜가 없거나 끝이 시작보다 빠르면 건너뛴다', () => {
expect(festivalItemToEvent(item({ eventenddate: '' }))).toEqual({
skip: 'bad_dates',
});
expect(
festivalItemToEvent(
item({ eventstartdate: '20261210', eventenddate: '20261201' }),
),
).toEqual({ skip: 'bad_dates' });
});
});
describe('festivalDetail', () => {
it('요금은 usetimefestival 에서, 행사 홈페이지를 우선해 읽는다', () => {
expect(
festivalDetail(
{
overview: '수성못에서 열리는 빛 축제<br>야간 조명',
homepage: '<a href="https://www.suseong.kr">수성구</a>',
},
{
usetimefestival: '무료',
playtime: '17:00~22:00',
eventplace: '수성못 일원',
eventhomepage: '<a href="https://light.suseong.kr">행사</a>',
},
),
).toEqual({
overview: '수성못에서 열리는 빛 축제\n야간 조명',
homepage: 'https://light.suseong.kr',
feeText: '무료',
playtimeText: '17:00~22:00',
eventPlace: '수성못 일원',
});
});
});
@@ -0,0 +1,124 @@
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';
import type { TourItem } from './tour-api.client';
/**
* 관광공사 축제·공연·행사(searchFestival2·detailIntro2 관광타입 15) → festival_event 컬럼 (순수 함수).
*/
/** lclsSystm2 → 종류. 분류가 비면 행사로 본다 */
const KIND_BY_LCLS2: Record<string, EventKind> = {
EV01: 'festival',
EV02: 'performance',
EV03: 'event',
};
export interface FestivalListEvent {
sourceId: string;
kind: EventKind;
sourceCategory: string | null;
name: string;
startDate: string;
endDate: string;
lat: number | null;
lng: number | null;
address: string | null;
ldongRegnCd: string | null;
ldongSignguCd: string | null;
phone: string | null;
imageUrl: string | null;
imageThumbUrl: string | null;
imageLicense: ImageLicense | null;
sourceModifiedAt: Date | null;
}
export type FestivalSkipReason = 'no_id' | 'bad_dates';
function str(item: TourItem, key: string): string | null {
const v = item[key];
if (typeof v !== 'string' && typeof v !== 'number') return null;
const s = String(v).trim();
return s === '' ? null : s;
}
/** 'YYYYMMDD' → 'YYYY-MM-DD'. 없는 날짜면 null */
export function toIsoDate(value: string | null): string | null {
const m = value ? /^(\d{4})(\d{2})(\d{2})$/.exec(value) : null;
if (!m) return null;
const iso = `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(`${iso}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().startsWith(iso)
? iso
: null;
}
/** 목록 항목 1건 → festival_event 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
export function festivalItemToEvent(
item: TourItem,
): FestivalListEvent | { skip: FestivalSkipReason } {
const sourceId = str(item, 'contentid');
const name = htmlToText(item.title);
if (!sourceId || !name) return { skip: 'no_id' };
const startDate = toIsoDate(str(item, 'eventstartdate'));
const endDate = toIsoDate(str(item, 'eventenddate'));
if (!startDate || !endDate || endDate < startDate) {
return { skip: 'bad_dates' };
}
const lat = Number(str(item, 'mapy'));
const lng = Number(str(item, 'mapx'));
const hasCoords = isInKorea(lat, lng);
const lcls2 = str(item, 'lclsSystm2');
const license = str(item, 'cpyrhtDivCd');
const address = [str(item, 'addr1'), str(item, 'addr2')]
.filter(Boolean)
.join(' ');
return {
sourceId,
kind: (lcls2 && KIND_BY_LCLS2[lcls2]) || 'event',
sourceCategory: str(item, 'lclsSystm3') ?? lcls2,
name,
startDate,
endDate,
lat: hasCoords ? lat : null,
lng: hasCoords ? lng : null,
address: address === '' ? null : address,
ldongRegnCd: str(item, 'lDongRegnCd'),
ldongSignguCd: str(item, 'lDongSignguCd'),
phone: htmlToText(item.tel),
imageUrl: preferHttps(str(item, 'firstimage')),
imageThumbUrl: preferHttps(str(item, 'firstimage2')),
imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '')
? (license as ImageLicense)
: null,
sourceModifiedAt: parseKstTimestamp(str(item, 'modifiedtime')),
};
}
export interface FestivalDetail {
overview: string | null;
homepage: string | null;
feeText: string | null;
playtimeText: string | null;
eventPlace: string | null;
}
/**
* 공통정보(detailCommon2) + 소개정보(detailIntro2, 관광타입 15) → 상세 컬럼.
* 요금은 이름과 달리 usetimefestival 에 들어 있다. 행사 홈페이지(eventhomepage)가 있으면 그쪽을 쓴다.
*/
export function festivalDetail(
common: TourItem,
intro: TourItem,
): FestivalDetail {
return {
overview: htmlToText(common.overview),
homepage: extractUrl(intro.eventhomepage) ?? extractUrl(common.homepage),
feeText: htmlToText(intro.usetimefestival),
playtimeText: htmlToText(intro.playtime),
eventPlace: htmlToText(intro.eventplace),
};
}
@@ -0,0 +1,380 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThanOrEqual, Repository } from 'typeorm';
import { kstDateString } from '@/shared/utils/kst';
import { FestivalEvent } from '../entities/festival-event.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import {
FestivalListEvent,
FestivalSkipReason,
festivalDetail,
festivalItemToEvent,
} from './festival-mapper';
import { PublicDataApiFactory } from './public-data.factory';
import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
/**
* 행사를 받을 시·도 — 대구(27)만으로는 90일에 1건뿐이라(2026-11-03 실측: 대구 1 · 경북 7) 근교 경북(47)까지 받는다.
* 화면은 동네 좌표에서 거리순으로 보여 주므로 대구 행사가 먼저 나온다.
*/
export const DEFAULT_EVENT_REGIONS = ['27', '47'];
/** 오늘부터 며칠 안에 열리는 행사를 받을지 */
export const DEFAULT_EVENT_LOOKAHEAD_DAYS = 90;
/** 관광공사 축제·공연·행사 관광타입 */
const FESTIVAL_TYPE_ID = '15';
const PAGE_SIZE = 500; // 2026-10 점검에서 500 이 동작함
const MAX_PAGES = 20;
const UPSERT_CHUNK = 200;
const MAX_CONSECUTIVE_FAILURES = 10;
/** upsert 때 덮어쓰는 컬럼(상세 컬럼은 상세를 받을 때만 쓴다) */
const OVERWRITE_PROPS = [
'kind',
'sourceCategory',
'name',
'startDate',
'endDate',
'lat',
'lng',
'address',
'ldongRegnCd',
'ldongSignguCd',
'phone',
'imageUrl',
'imageThumbUrl',
'imageLicense',
'sourceModifiedAt',
'status',
'lastSeenAt',
'detailSyncedAt',
'updatedAt',
] as const satisfies readonly (keyof FestivalEvent)[];
type SkipReason = FestivalSkipReason | 'other_region';
export interface FestivalSyncResult {
window: { from: string; to: string };
fetched: number;
totalCount: number;
inserted: number;
updated: number;
hidden: number;
skipped: Partial<Record<SkipReason, number>>;
detailed: number;
detailFailed: number;
detailRemaining: number;
stoppedByBudget: boolean;
calls: Record<string, number>;
errors: string[];
}
const toYmd = (iso: string) => iso.replace(/-/g, '');
/**
* 관광공사 축제·공연·행사 동기화(매일). 오늘부터 N일 안에 열리는 지역 행사를 받아 festival_event 에 upsert 한다.
* searchFestival2 는 기간이 겹치는 행사를 준다(종료일 ≥ 요청 시작일). 상세는 새로 생기거나 바뀐 행사만 받는다.
* "60일 넘는 상시 프로그램 제외"는 저장할 때가 아니라 조회 API 에서 적용한다.
*/
@Injectable()
export class FestivalSync {
private readonly logger = new Logger(FestivalSync.name);
constructor(
@InjectRepository(FestivalEvent)
private readonly events: Repository<FestivalEvent>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<FestivalSyncResult[]> {
const results: FestivalSyncResult[] = [];
for (const regionCode of options.regionCodes) {
results.push(await this.run({ regionCode, days: options.days }));
}
return results;
}
async run(options: {
regionCode: string;
days?: number;
}): Promise<FestivalSyncResult> {
const { regionCode } = options;
const now = new Date();
const window = {
from: kstDateString(now),
to: kstDateString(now, options.days ?? DEFAULT_EVENT_LOOKAHEAD_DAYS),
};
const budget = new CallBudget(
this.apiFactory.tourDailyLimit(),
await this.recorder.usedToday(),
);
const api = this.apiFactory.createTour(budget);
const run = await this.recorder.start('event', regionCode);
const result: FestivalSyncResult = {
window,
fetched: 0,
totalCount: 0,
inserted: 0,
updated: 0,
hidden: 0,
skipped: {},
detailed: 0,
detailFailed: 0,
detailRemaining: 0,
stoppedByBudget: false,
calls: {},
errors: [],
};
try {
await this.syncList(api, regionCode, window, result);
if (!result.stoppedByBudget) {
await this.syncDetails(api, budget, regionCode, window.from, result);
}
result.detailRemaining = await this.events.countBy({
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
endDate: MoreThanOrEqual(window.from),
});
} catch (error) {
result.errors.unshift(
error instanceof Error ? error.message : String(error),
);
throw error;
} finally {
result.calls = budget.spent();
await this.recorder.finish(run, {
fetched: result.fetched,
inserted: result.inserted,
updated: result.updated,
hidden: result.hidden,
calls: result.calls,
errors: result.errors,
});
this.report(result);
}
return result;
}
private async syncList(
api: TourApi,
regionCode: string,
window: { from: string; to: string },
result: FestivalSyncResult,
): Promise<void> {
const runStart = new Date();
// 관광공사 행사만 다룬다(KOPIS 공연 등 다른 출처 행사는 각자의 동기화가 관리한다)
const existing = await this.events.find({
where: { source: 'tour' },
select: {
id: true,
sourceId: true,
sourceModifiedAt: true,
detailSyncedAt: true,
status: true,
ldongRegnCd: true,
startDate: true,
endDate: true,
},
});
const bySourceId = new Map(existing.map((e) => [e.sourceId, e]));
const rows: Array<FestivalListEvent & Partial<FestivalEvent>> = [];
let complete = false;
for (let page = 1; page <= MAX_PAGES; page += 1) {
let items: TourItem[];
try {
const res = await api.get('searchFestival2', {
eventStartDate: toYmd(window.from),
eventEndDate: toYmd(window.to),
lDongRegnCd: regionCode,
numOfRows: PAGE_SIZE,
pageNo: page,
arrange: 'A',
});
items = res.items;
result.totalCount = res.totalCount;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
throw error;
}
result.fetched += items.length;
for (const item of items) {
const mapped = festivalItemToEvent(item);
if ('skip' in mapped) {
result.skipped[mapped.skip] = (result.skipped[mapped.skip] ?? 0) + 1;
continue;
}
// 지역 조건을 서버가 무시하더라도 다른 지역 행사는 넣지 않는다
if (mapped.ldongRegnCd && mapped.ldongRegnCd !== regionCode) {
result.skipped.other_region = (result.skipped.other_region ?? 0) + 1;
continue;
}
const prev = bySourceId.get(mapped.sourceId);
const changed =
!prev ||
prev.sourceModifiedAt?.getTime() !==
mapped.sourceModifiedAt?.getTime();
rows.push({
...mapped,
source: 'tour',
ldongRegnCd: mapped.ldongRegnCd ?? regionCode,
status: 'active',
lastSeenAt: runStart,
updatedAt: runStart,
detailSyncedAt: changed ? null : (prev?.detailSyncedAt ?? null),
});
if (prev) result.updated += 1;
else result.inserted += 1;
}
if (items.length === 0 || result.fetched >= result.totalCount) {
complete = true;
break;
}
}
await this.upsert(rows);
if (!complete) return;
// 받은 기간과 겹치는데 이번 목록에 없는 행사는 숨긴다(취소·비공개 전환)
const seen = new Set(rows.map((r) => r.sourceId));
const inWindowBefore = existing.filter(
(e) =>
e.status === 'active' &&
e.ldongRegnCd === regionCode &&
e.endDate >= window.from &&
e.startDate <= window.to,
);
const missing = inWindowBefore.filter((e) => !seen.has(e.sourceId)).length;
if (isSuspiciousRemoval(inWindowBefore.length, missing)) {
result.errors.push(
`기간 안 행사 ${inWindowBefore.length}건 중 ${missing}건이 목록에 없어 원본 장애로 보고 숨김 처리를 건너뜀`,
);
return;
}
const hidden = await this.events
.createQueryBuilder()
.update(FestivalEvent)
.set({ status: 'hidden' })
.where('source = :source', { source: 'tour' })
.andWhere('ldong_regn_cd = :region', { region: regionCode })
.andWhere('status = :active', { active: 'active' })
.andWhere('end_date >= :from AND start_date <= :to', window)
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
runStart,
})
.execute();
result.hidden = hidden.affected ?? 0;
}
private async upsert(rows: Partial<FestivalEvent>[]): Promise<void> {
const overwrite = OVERWRITE_PROPS.map(
(prop) =>
this.events.metadata.findColumnWithPropertyName(prop)!.databaseName,
);
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
await this.events
.createQueryBuilder()
.insert()
.into(FestivalEvent)
.values(rows.slice(i, i + UPSERT_CHUNK))
.orUpdate(overwrite, ['source', 'source_id'])
.execute();
}
}
private async syncDetails(
api: TourApi,
budget: CallBudget,
regionCode: string,
today: string,
result: FestivalSyncResult,
): Promise<void> {
const pending = await this.events.find({
where: {
source: 'tour',
ldongRegnCd: regionCode,
status: 'active',
detailSyncedAt: IsNull(),
endDate: MoreThanOrEqual(today),
},
select: { id: true, sourceId: true },
order: { startDate: 'ASC' },
});
let consecutiveFailures = 0;
for (const event of pending) {
if (
!budget.canSpend('detailCommon2') ||
!budget.canSpend('detailIntro2')
) {
result.stoppedByBudget = true;
break;
}
try {
const common = await api.get('detailCommon2', {
contentId: event.sourceId,
});
const intro = await api.get('detailIntro2', {
contentId: event.sourceId,
contentTypeId: FESTIVAL_TYPE_ID,
});
await this.events.update(event.id, {
...festivalDetail(common.items[0] ?? {}, intro.items[0] ?? {}),
detailSyncedAt: new Date(),
});
result.detailed += 1;
consecutiveFailures = 0;
} catch (error) {
if (error instanceof BudgetExceededError) {
result.stoppedByBudget = true;
result.errors.push(error.message);
break;
}
result.detailFailed += 1;
consecutiveFailures += 1;
result.errors.push(
`${event.sourceId}: ${error instanceof Error ? error.message : String(error)}`,
);
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
result.errors.push(
`상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`,
);
break;
}
}
}
}
private report(r: FestivalSyncResult): void {
const skipped = Object.entries(r.skipped)
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`축제·행사(${r.window.from} ~ ${r.window.to}) ${r.fetched}/${r.totalCount}건 → 새 행사 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` +
(skipped ? ` · 건너뜀(${skipped})` : ''),
);
this.logger.log(
`상세 ${r.detailed}건 받음 · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` +
(r.stoppedByBudget
? ' — 오늘 한도에 닿아 멈춤, 내일 다시 실행하면 이어서 받는다'
: ''),
);
this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
}
}

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