feat(backend): AI 코스 생성 API 골격과 생성 기록 테이블 추가 #13

Merged
ttipo merged 2 commits from feat/course-generation-api into main 2026-11-11 15:01:58 +09:00
Owner

무엇을 · 왜

AI 코스 생성의 API·생성 기록 테이블·에러 코드를 세운다. 구현 순서의 4단계(설계 §14)다.

AI 자리는 가짜 클라이언트로 채웠다. 키 없이도 접수부터 결과까지 생성 흐름을 끝까지 돌려 볼 수 있다. 앞 단계에서 만든 순수 로직(2b 시간표·검증·고치기)과 날씨·공휴일(3단계)을 실제 DB 후보에 연결한다. 실제 모델 호출과 프롬프트는 5단계에서 이 자리를 바꾼다.

API — backend/src/modules/course-generation/

모든 요청에 X-Device-Id(UUID) 헤더가 필요하다. 서버는 SHA-256 으로 바꿔서만 저장한다.

API 하는 일 요청 한도(분당)
GET /course-generations/quota 오늘 남은 횟수와 초기화 시각(다음 한국 자정) 300
POST /course-conditions 요청 문장을 조건으로 바꾸고 그날 날씨를 붙인다. 가짜 AI 는 문장을 읽지 않고 기본값만 채운다 20
POST /course-generations 생성 접수(201). 기록을 만들고 백그라운드 실행기에 넘긴다 5
GET /course-generations/:id 진행 단계·결과·실패 이유 조회(폴링) 300
POST /course-generations/:id/cancellation 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다 기본
PUT /course-generations/:id/rating 좋아요/아쉬워요. 결과가 나온 생성만 받는다 기본

다른 기기의 생성은 없는 것처럼 RES_001 로 답한다.

생성 흐름

단계(stage) 하는 일
searching 지역(동네·현재 위치·장소)을 중심점으로 바꾼다. 장소 후보, 행사 후보, 날씨, 이전 코스 장소를 함께 가져온다
filtering 공휴일과 비 여부로 조건을 만든다. 거르기·줄이기를 하고, 2곳 미만이면 AI_003 으로 끝낸다
planning AI 초안을 받는다. 시간표를 짜고 검증·고치기를 한다. 고칠 수 없으면 시간이 남을 때 AI 를 한 번 더 부른다
finishing 결과를 조립한다. DB 의 사실 값(그날 영업시간·반려동물 조건·출처)과 AI 의 제목·이유를 합친다

결과의 시각은 ISO UTC 다(API 규약). 장소마다 facts.hoursOnDate(그날 영업시간)를 채운다. 코드가 남긴 알림은 planNotices 에 담는다.

횟수·비용 보호

무엇 정한 것
하루 횟수 비로그인 기기당 2번. 결과가 나온 생성만 차감한다(counted)
시도 상한 실패·취소를 포함해 하루 횟수의 3배까지만 받는다 — 일부러 실패시켜 AI 를 쓰는 것을 막는다
하루 비용 상한 그날 cost_usd 합이 상한을 넘으면 AI_002 로 막고 error 로그를 남긴다
동시 생성 같은 기기에서 진행 중이면 AI_004. 같은 기기의 동시 요청은 advisory lock 으로 하나씩 검사한다
설정 AI_DAILY_LIMIT_GUEST·AI_DAILY_LIMIT_MEMBER·AI_DAILY_BUDGET_USD·AI_TIMEOUT_MS. 없으면 기본값(2·5·3달러·30초)을 쓰고, 숫자가 아니면 기동하지 않는다

실행기(프로세스 안)

  • 동시에 5건까지 돌리고 나머지는 queued 로 기다린다
  • 작업마다 45초 제한이다. 넘으면 AI_002 로 실패하고, 취소하면 AbortController 로 AI 호출을 끊는다
  • 실패·취소는 횟수를 차감하지 않는다
  • 기동할 때 남은 queued·running 은 모두 실패로 바꾼다(서버가 한 대라 모두 지난 프로세스의 것이다)
  • 종료할 때 진행 중인 작업도 실패로 적는다
  • 1분마다 2분 넘게 running 인 작업을 정리한다

서버를 늘리면 Redis 큐(BullMQ)로 옮긴다(설계 §3.1).

첫 CI 에서 고친 것 (7ed3411)

빈 DB 로 도는 CI 에서 e2e 가 멈췄다.

  • 원인 1: e2e 스위트가 app.init() 을 마이그레이션보다 먼저 불렀다. 그래서 실행기의 기동 정리가 아직 없는 ai_course_generation 테이블을 읽다 실패했다.
  • 원인 2: 그 뒤 afterAll 에서 app.close() 까지 가지 못했다. 1분마다 도는 정리 작업이 남아 Jest 가 끝나지 않았다.

고친 것:

  • 실행기의 기동 정리는 실패해도 기동을 막지 않는다. SYS_001 로그만 남긴다.
  • e2e 3개(course-generation·place·festival)는 마이그레이션을 app.init() 전에 돌린다.
  • afterAll 은 정리가 실패해도 app.close() 를 부른다.
  • 빈 임시 DB 로 e2e 전체(6개 스위트, 57개)가 통과하고 Jest 가 정상 종료하는 것을 확인했다.

저장하는 것

  • ai_course_generation 테이블: 설계 §6.1 열에 failure_notices·started_at·repairs 를 더했다
  • 요청 문장의 이메일·전화번호는 저장 전에 가린다(shared/utils/mask-contact.ts)
  • 현재 위치는 소수점 셋째 자리(약 100m)까지만 남긴다

함께 바꾼 것

  • shared/utils/opening-hours.ts: hoursOn(parsed, date) 를 더했다. 결과의 hoursOnDate 에 쓴다
  • place.sql.ts: 행사 최대 기간 조건을 sqlEventWithinMaxSpan() 으로 뺐다. 축제 목록과 코스 후보가 같은 조건을 쓴다
  • 설계 문서(docs/design/ai-course.md)에 구현하며 정한 것을 적었다: §3.1 실행기, §3.7 결과 형식, §4, §6.1, §11, §14

확인 방법

# 개발 DB 에 마이그레이션 적용
docker exec wandergo-backend npm run migration:run

# 단위 테스트 · e2e
docker exec wandergo-backend npx jest src/modules/course-generation src/shared/utils
docker exec wandergo-backend npx jest --config ./test/jest-e2e.json test/course-generation.e2e-spec.ts
  • 단위 테스트 23개를 새로 넣었다(조건 검증·기본값, 가짜 AI, 결과 조립, 기기 ID, 연락처 가리기, 그날 영업시간)
  • e2e 9개를 새로 넣었다. 실제 HTTP·DB 로 아래를 확인한다:
    • 접수 → 결과(가린 문장 · 차감 · 반올림한 위치)
    • 다른 기기 차단, 평가
    • 진행 중 막기(AI_004)와 취소
    • 하루 횟수 초과(AI_001), 후보 없음(AI_003), AI 장애(AI_002), 잘못된 조건(VAL_001)
    • e2e 는 대구 범위 남서쪽 끝에 e2e-cg- 시험 장소를 만들고 끝나면 지운다. 날씨는 막아 두어 키를 쓰지 않는다
  • 실제 대구 데이터로 한 번 돌렸다(개발 DB, 기상청 예보 범위 밖 날짜라 키를 쓰지 않았다)
    • 조건: 대구 도심, 13~19시, 걸어서
    • 후보 95곳 → 거른 뒤 88곳
    • 결과: 동성로거리 → 한옥집김치찜 → 카페 접속 → 경상감영공원(4곳). 바꾸기 후보 4곳도 붙었다
    • 시험 기록은 지웠다

영향 범위

  • backend
  • frontend — 에러 사전 생성 파일만
  • flutter — 에러 사전 생성 파일만
  • docker / 인프라
  • 규칙 문서(.claude/)

공유 계약: 에러 코드 AI_001(429)·AI_002(503)·AI_003(422)·AI_004(409)를 레지스트리에 더하고 npm run sync:error-codes 로 웹·앱 사전을 다시 만들었다. 기존 코드와 응답 형식은 바뀌지 않는다. 클라이언트는 아직 새 코드로 분기하지 않으므로 계약 테스트의 기대 목록은 그대로다. 앱 화면 연결은 6단계에서 한다.

체크리스트

  • 린트/분석 통과 (Warning 0) · 변경 영역 테스트 통과: lint · tsc · 백엔드 단위 테스트 389개 · e2e 57개 · 앱 에러 사전 테스트·analyze
  • 주석 한국어, 매직 넘버·하드코딩 문자열 없음: 기준값은 course-generation.constants.ts 에 모았다
  • 사용자 노출 실패는 모두 에러 코드와 함께 처리: AI_001~AI_004 · VAL_001 · RES_001
  • 에러 코드를 바꿨다면 npm run sync:error-codes 실행 후 생성 파일까지 커밋
  • 엔티티와 마이그레이션 함께 커밋, 생성된 SQL 검토(새 테이블과 인덱스 3개만 만든다)
  • 민감정보 미커밋
  • 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음: 요청 문장은 로그에 남기지 않는다. 기기 ID 는 해시만 쓴다

🤖 Generated with Claude Code

## 무엇을 · 왜 AI 코스 생성의 API·생성 기록 테이블·에러 코드를 세운다. 구현 순서의 4단계(설계 §14)다. AI 자리는 가짜 클라이언트로 채웠다. 키 없이도 접수부터 결과까지 생성 흐름을 끝까지 돌려 볼 수 있다. 앞 단계에서 만든 순수 로직(2b 시간표·검증·고치기)과 날씨·공휴일(3단계)을 실제 DB 후보에 연결한다. 실제 모델 호출과 프롬프트는 5단계에서 이 자리를 바꾼다. ### API — `backend/src/modules/course-generation/` 모든 요청에 `X-Device-Id`(UUID) 헤더가 필요하다. 서버는 SHA-256 으로 바꿔서만 저장한다. | API | 하는 일 | 요청 한도(분당) | | --- | --- | --- | | `GET /course-generations/quota` | 오늘 남은 횟수와 초기화 시각(다음 한국 자정) | 300 | | `POST /course-conditions` | 요청 문장을 조건으로 바꾸고 그날 날씨를 붙인다. 가짜 AI 는 문장을 읽지 않고 기본값만 채운다 | 20 | | `POST /course-generations` | 생성 접수(201). 기록을 만들고 백그라운드 실행기에 넘긴다 | 5 | | `GET /course-generations/:id` | 진행 단계·결과·실패 이유 조회(폴링) | 300 | | `POST /course-generations/:id/cancellation` | 기다리는 중이면 빼고, 도는 중이면 AI 호출을 끊는다 | 기본 | | `PUT /course-generations/:id/rating` | 좋아요/아쉬워요. 결과가 나온 생성만 받는다 | 기본 | 다른 기기의 생성은 없는 것처럼 `RES_001` 로 답한다. ### 생성 흐름 | 단계(`stage`) | 하는 일 | | --- | --- | | `searching` | 지역(동네·현재 위치·장소)을 중심점으로 바꾼다. 장소 후보, 행사 후보, 날씨, 이전 코스 장소를 함께 가져온다 | | `filtering` | 공휴일과 비 여부로 조건을 만든다. 거르기·줄이기를 하고, 2곳 미만이면 `AI_003` 으로 끝낸다 | | `planning` | AI 초안을 받는다. 시간표를 짜고 검증·고치기를 한다. 고칠 수 없으면 시간이 남을 때 AI 를 한 번 더 부른다 | | `finishing` | 결과를 조립한다. DB 의 사실 값(그날 영업시간·반려동물 조건·출처)과 AI 의 제목·이유를 합친다 | 결과의 시각은 ISO UTC 다(API 규약). 장소마다 `facts.hoursOnDate`(그날 영업시간)를 채운다. 코드가 남긴 알림은 `planNotices` 에 담는다. ### 횟수·비용 보호 | 무엇 | 정한 것 | | --- | --- | | 하루 횟수 | 비로그인 기기당 2번. 결과가 나온 생성만 차감한다(`counted`) | | 시도 상한 | 실패·취소를 포함해 하루 횟수의 3배까지만 받는다 — 일부러 실패시켜 AI 를 쓰는 것을 막는다 | | 하루 비용 상한 | 그날 `cost_usd` 합이 상한을 넘으면 `AI_002` 로 막고 error 로그를 남긴다 | | 동시 생성 | 같은 기기에서 진행 중이면 `AI_004`. 같은 기기의 동시 요청은 advisory lock 으로 하나씩 검사한다 | | 설정 | `AI_DAILY_LIMIT_GUEST`·`AI_DAILY_LIMIT_MEMBER`·`AI_DAILY_BUDGET_USD`·`AI_TIMEOUT_MS`. 없으면 기본값(2·5·3달러·30초)을 쓰고, 숫자가 아니면 기동하지 않는다 | ### 실행기(프로세스 안) - 동시에 5건까지 돌리고 나머지는 `queued` 로 기다린다 - 작업마다 45초 제한이다. 넘으면 `AI_002` 로 실패하고, 취소하면 AbortController 로 AI 호출을 끊는다 - 실패·취소는 횟수를 차감하지 않는다 - 기동할 때 남은 `queued`·`running` 은 모두 실패로 바꾼다(서버가 한 대라 모두 지난 프로세스의 것이다) - 종료할 때 진행 중인 작업도 실패로 적는다 - 1분마다 2분 넘게 `running` 인 작업을 정리한다 서버를 늘리면 Redis 큐(BullMQ)로 옮긴다(설계 §3.1). #### 첫 CI 에서 고친 것 (`7ed3411`) 빈 DB 로 도는 CI 에서 e2e 가 멈췄다. - **원인 1:** e2e 스위트가 `app.init()` 을 마이그레이션보다 먼저 불렀다. 그래서 실행기의 기동 정리가 아직 없는 `ai_course_generation` 테이블을 읽다 실패했다. - **원인 2:** 그 뒤 `afterAll` 에서 `app.close()` 까지 가지 못했다. 1분마다 도는 정리 작업이 남아 Jest 가 끝나지 않았다. 고친 것: - 실행기의 기동 정리는 실패해도 기동을 막지 않는다. `SYS_001` 로그만 남긴다. - e2e 3개(course-generation·place·festival)는 마이그레이션을 `app.init()` 전에 돌린다. - `afterAll` 은 정리가 실패해도 `app.close()` 를 부른다. - 빈 임시 DB 로 e2e 전체(6개 스위트, 57개)가 통과하고 Jest 가 정상 종료하는 것을 확인했다. ### 저장하는 것 - `ai_course_generation` 테이블: 설계 §6.1 열에 `failure_notices`·`started_at`·`repairs` 를 더했다 - 요청 문장의 이메일·전화번호는 저장 전에 가린다(`shared/utils/mask-contact.ts`) - 현재 위치는 소수점 셋째 자리(약 100m)까지만 남긴다 ### 함께 바꾼 것 - `shared/utils/opening-hours.ts`: `hoursOn(parsed, date)` 를 더했다. 결과의 `hoursOnDate` 에 쓴다 - `place.sql.ts`: 행사 최대 기간 조건을 `sqlEventWithinMaxSpan()` 으로 뺐다. 축제 목록과 코스 후보가 같은 조건을 쓴다 - 설계 문서(`docs/design/ai-course.md`)에 구현하며 정한 것을 적었다: §3.1 실행기, §3.7 결과 형식, §4, §6.1, §11, §14 ## 확인 방법 ```bash # 개발 DB 에 마이그레이션 적용 docker exec wandergo-backend npm run migration:run # 단위 테스트 · e2e docker exec wandergo-backend npx jest src/modules/course-generation src/shared/utils docker exec wandergo-backend npx jest --config ./test/jest-e2e.json test/course-generation.e2e-spec.ts ``` - 단위 테스트 23개를 새로 넣었다(조건 검증·기본값, 가짜 AI, 결과 조립, 기기 ID, 연락처 가리기, 그날 영업시간) - e2e 9개를 새로 넣었다. 실제 HTTP·DB 로 아래를 확인한다: - 접수 → 결과(가린 문장 · 차감 · 반올림한 위치) - 다른 기기 차단, 평가 - 진행 중 막기(`AI_004`)와 취소 - 하루 횟수 초과(`AI_001`), 후보 없음(`AI_003`), AI 장애(`AI_002`), 잘못된 조건(`VAL_001`) - e2e 는 대구 범위 남서쪽 끝에 `e2e-cg-` 시험 장소를 만들고 끝나면 지운다. 날씨는 막아 두어 키를 쓰지 않는다 - 실제 대구 데이터로 한 번 돌렸다(개발 DB, 기상청 예보 범위 밖 날짜라 키를 쓰지 않았다) - 조건: 대구 도심, 13~19시, 걸어서 - 후보 95곳 → 거른 뒤 88곳 - 결과: 동성로거리 → 한옥집김치찜 → 카페 접속 → 경상감영공원(4곳). 바꾸기 후보 4곳도 붙었다 - 시험 기록은 지웠다 ## 영향 범위 - [x] backend - [x] frontend — 에러 사전 생성 파일만 - [x] flutter — 에러 사전 생성 파일만 - [ ] docker / 인프라 - [ ] 규칙 문서(.claude/) > **공유 계약**: 에러 코드 `AI_001`(429)·`AI_002`(503)·`AI_003`(422)·`AI_004`(409)를 레지스트리에 더하고 `npm run sync:error-codes` 로 웹·앱 사전을 다시 만들었다. 기존 코드와 응답 형식은 바뀌지 않는다. 클라이언트는 아직 새 코드로 분기하지 않으므로 계약 테스트의 기대 목록은 그대로다. 앱 화면 연결은 6단계에서 한다. ## 체크리스트 - [x] 린트/분석 통과 (Warning 0) · 변경 영역 테스트 통과: lint · tsc · 백엔드 단위 테스트 389개 · e2e 57개 · 앱 에러 사전 테스트·analyze - [x] 주석 한국어, 매직 넘버·하드코딩 문자열 없음: 기준값은 `course-generation.constants.ts` 에 모았다 - [x] 사용자 노출 실패는 모두 에러 코드와 함께 처리: `AI_001`~`AI_004` · `VAL_001` · `RES_001` - [x] 에러 코드를 바꿨다면 `npm run sync:error-codes` 실행 후 생성 파일까지 커밋 - [x] 엔티티와 마이그레이션 함께 커밋, 생성된 SQL 검토(새 테이블과 인덱스 3개만 만든다) - [x] 민감정보 미커밋 - [x] 로그에 비밀번호·토큰·요청 본문 전체가 남지 않음: 요청 문장은 로그에 남기지 않는다. 기기 ID 는 해시만 쓴다 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ttipo added 1 commit 2026-11-11 11:38:42 +09:00
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
09c8decb15
설계 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>
ttipo added 1 commit 2026-11-11 14:01:30 +09:00
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
7ed3411abb
CI 의 빈 DB 에서 e2e 가 앱을 띄운 뒤에 마이그레이션을 돌려, 실행기의 기동 정리가
아직 없는 테이블을 찾다가 기동이 실패했다. 앱을 닫지 못해 1분 주기 정리 작업이 남아
jest 가 끝나지 않았고 CI 가 러너 제한 시간까지 멈춰 있었다.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ttipo merged commit 7ed3411abb into main 2026-11-11 15:01:58 +09:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ttipo/wandergo#13