From 187a99ca7f7393fc22fc4eb8e5cb54b2cc58c6cf Mon Sep 17 00:00:00 2001 From: ttipo Date: Sun, 8 Nov 2026 15:39:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EA=B8=B0=EC=83=81=EC=B2=AD=20?= =?UTF-8?q?=EB=8B=A8=EA=B8=B0=EC=98=88=EB=B3=B4=EC=99=80=20=EA=B3=B5?= =?UTF-8?q?=ED=9C=B4=EC=9D=BC=20=ED=91=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/package.json | 3 +- .../src/commands/weather-check.cli.module.ts | 23 ++ backend/src/commands/weather-check.ts | 113 ++++++++ .../course-generation/planner/filter.spec.ts | 12 +- .../course-generation/planner/filter.ts | 9 +- .../planner/planner.fixtures.ts | 2 +- .../planner/planner.types.ts | 5 +- .../modules/place/sync/public-data.client.ts | 18 +- .../weather/kma-forecast.client.spec.ts | 108 ++++++++ .../modules/weather/kma-forecast.client.ts | 102 +++++++ .../src/modules/weather/kma-forecast.spec.ts | 242 +++++++++++++++++ backend/src/modules/weather/kma-forecast.ts | 252 ++++++++++++++++++ backend/src/modules/weather/kma-grid.spec.ts | 11 + backend/src/modules/weather/kma-grid.ts | 45 ++++ backend/src/modules/weather/weather.module.ts | 20 ++ .../modules/weather/weather.service.spec.ts | 166 ++++++++++++ .../src/modules/weather/weather.service.ts | 167 ++++++++++++ backend/src/shared/utils/kr-holidays.spec.ts | 131 +++++++++ backend/src/shared/utils/kr-holidays.ts | 118 ++++++++ docs/design/ai-course.md | 16 +- 20 files changed, 1547 insertions(+), 16 deletions(-) create mode 100644 backend/src/commands/weather-check.cli.module.ts create mode 100644 backend/src/commands/weather-check.ts create mode 100644 backend/src/modules/weather/kma-forecast.client.spec.ts create mode 100644 backend/src/modules/weather/kma-forecast.client.ts create mode 100644 backend/src/modules/weather/kma-forecast.spec.ts create mode 100644 backend/src/modules/weather/kma-forecast.ts create mode 100644 backend/src/modules/weather/kma-grid.spec.ts create mode 100644 backend/src/modules/weather/kma-grid.ts create mode 100644 backend/src/modules/weather/weather.module.ts create mode 100644 backend/src/modules/weather/weather.service.spec.ts create mode 100644 backend/src/modules/weather/weather.service.ts create mode 100644 backend/src/shared/utils/kr-holidays.spec.ts create mode 100644 backend/src/shared/utils/kr-holidays.ts diff --git a/backend/package.json b/backend/package.json index 36b20fc..96972dd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,8 @@ "migration:revert": "npm run typeorm -- migration:revert", "migration:show": "npm run typeorm -- migration:show", "place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts", - "place:sync:prod": "node dist/commands/place-sync.js" + "place:sync:prod": "node dist/commands/place-sync.js", + "weather:check": "ts-node -r tsconfig-paths/register src/commands/weather-check.ts" }, "dependencies": { "@nestjs/cache-manager": "^3.0.1", diff --git a/backend/src/commands/weather-check.cli.module.ts b/backend/src/commands/weather-check.cli.module.ts new file mode 100644 index 0000000..00d28e8 --- /dev/null +++ b/backend/src/commands/weather-check.cli.module.ts @@ -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 {} diff --git a/backend/src/commands/weather-check.ts b/backend/src/commands/weather-check.ts new file mode 100644 index 0000000..9aad041 --- /dev/null +++ b/backend/src/commands/weather-check.ts @@ -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 { + 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; +}); diff --git a/backend/src/modules/course-generation/planner/filter.spec.ts b/backend/src/modules/course-generation/planner/filter.spec.ts index 0feaf20..5d71fb3 100644 --- a/backend/src/modules/course-generation/planner/filter.spec.ts +++ b/backend/src/modules/course-generation/planner/filter.spec.ts @@ -44,10 +44,18 @@ describe('exclusionReason', () => { ).toBeNull(); }); - it('공휴일·명절이고 명절·공휴일 휴무 표시가 있으면 뺀다', () => { + it('그날 공휴일의 휴무 표시가 있으면 뺀다(설·추석은 연휴 내내, 공휴일 휴무는 모든 공휴일)', () => { const museum = place({ restText: '매주 월요일 / 설날·추석 당일' }); - expect(exclusionReason(museum, context({ holiday: true }))).toBe('holiday'); + 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('반려동물 조건이면 실내 미확인·동반 불가·크기 불일치를 빼고 야외 미확인은 남긴다', () => { diff --git a/backend/src/modules/course-generation/planner/filter.ts b/backend/src/modules/course-generation/planner/filter.ts index e723bf2..0ee3b55 100644 --- a/backend/src/modules/course-generation/planner/filter.ts +++ b/backend/src/modules/course-generation/planner/filter.ts @@ -3,6 +3,7 @@ * 비는 거르지 않는다(시간표 검증에서 실내 비율을 본다). */ import { distanceMeters } from '@/shared/utils/geo'; +import { closedOnHoliday } from '@/shared/utils/kr-holidays'; import { scheduleEnd, stayAvailability } from './availability'; import { @@ -44,7 +45,7 @@ function isRunningOn(c: Candidate, date: string): boolean { * 후보에서 빼야 하는 이유. 남기면 null. 비싼 영업 확인을 마지막에 한다. * - 영업: 시간 범위 안에서 분류별 최소 머무는 시간만큼 열려 있어야 한다 * - 영업시간 미확인: 미확인인 시각을 끼워야만 머물 수 있으면, 자연·관광지와 행사만 남긴다 - * - 공휴일: 그날이 공휴일·명절이고 휴무 표시(명절·공휴일 휴무)가 있으면 뺀다 + * - 공휴일: 그날이 공휴일·명절이고 그 공휴일의 휴무 표시가 있으면 뺀다('공휴일' 휴무는 모든 공휴일, 설·추석은 연휴 내내) */ export function exclusionReason( c: Candidate, @@ -57,7 +58,11 @@ export function exclusionReason( if (c.kind === 'event' && !isRunningOn(c, ctx.date)) { return 'event_not_running'; } - if (ctx.holiday && (c.hours?.holidayClosures.length ?? 0) > 0) { + if ( + ctx.holiday && + c.hours && + closedOnHoliday(c.hours.holidayClosures, ctx.holiday) + ) { return 'holiday'; } const pet = petExclusion(c, ctx.pet); diff --git a/backend/src/modules/course-generation/planner/planner.fixtures.ts b/backend/src/modules/course-generation/planner/planner.fixtures.ts index 7a1ccb3..07520ef 100644 --- a/backend/src/modules/course-generation/planner/planner.fixtures.ts +++ b/backend/src/modules/course-generation/planner/planner.fixtures.ts @@ -98,7 +98,7 @@ export function context(overrides: Partial = {}): PlanContext { mobility: 'walk', center: CENTER, pet: null, - holiday: false, + holiday: null, preferIndoor: false, previousIds: [], ...overrides, diff --git a/backend/src/modules/course-generation/planner/planner.types.ts b/backend/src/modules/course-generation/planner/planner.types.ts index 8fd5697..3d32c46 100644 --- a/backend/src/modules/course-generation/planner/planner.types.ts +++ b/backend/src/modules/course-generation/planner/planner.types.ts @@ -10,6 +10,7 @@ import type { 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'; @@ -56,8 +57,8 @@ export interface PlanContext { /** 지역 중심(동네 중심 · 현재 위치 · 기준 장소) */ center: LatLng; pet: PetCondition | null; - /** 그날이 공휴일·명절인가(공휴일 표는 3단계에서 붙인다) */ - holiday: boolean; + /** 그날의 공휴일 종류(shared/utils/kr-holidays 의 holidayOn). 공휴일이 아니면 null */ + holiday: HolidayKind | null; /** 비 조건 — 강수확률 40% 이상이고 indoorIfRain(prefersIndoor) */ preferIndoor: boolean; /** 다시 만들기 — 이전 결과의 장소·행사 id */ diff --git a/backend/src/modules/place/sync/public-data.client.ts b/backend/src/modules/place/sync/public-data.client.ts index d934cce..40284e5 100644 --- a/backend/src/modules/place/sync/public-data.client.ts +++ b/backend/src/modules/place/sync/public-data.client.ts @@ -48,6 +48,10 @@ export interface PublicDataClientSettings { keyParam?: string; /** 연속 호출 최소 간격(ms) */ intervalMs?: number; + /** 호출 하나의 제한 시간(ms) — 사용자 요청 중에 부르는 API(날씨)는 짧게 둔다 */ + timeoutMs?: number; + /** 네트워크 오류·5xx 때 몇 번까지 시도할지(처음 호출 포함) */ + maxAttempts?: number; } const defaultSleep = (ms: number) => @@ -88,6 +92,8 @@ export abstract class PublicDataClient { private readonly sleep: (ms: number) => Promise; private readonly keyParam: string; private readonly intervalMs: number; + private readonly timeoutMs: number; + private readonly maxAttempts: number; private lastCallAt = 0; protected constructor( @@ -100,6 +106,8 @@ export abstract class PublicDataClient { this.sleep = options.sleep ?? defaultSleep; this.keyParam = settings.keyParam ?? 'serviceKey'; this.intervalMs = settings.intervalMs ?? DEFAULT_CALL_INTERVAL_MS; + this.timeoutMs = settings.timeoutMs ?? REQUEST_TIMEOUT_MS; + this.maxAttempts = settings.maxAttempts ?? MAX_ATTEMPTS; } /** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */ @@ -124,12 +132,12 @@ export abstract class PublicDataClient { const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`; let lastError: unknown; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) { this.options.budget.take(operation); await this.pace(); try { const res = await this.fetchImpl(url, { - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), }); const body = await res.text(); if (res.status >= 500) { @@ -153,13 +161,15 @@ export abstract class PublicDataClient { } lastError = error; // 네트워크 오류·시간 초과 → 다시 시도 } - if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt); + if (attempt < this.maxAttempts) { + await this.sleep(RETRY_DELAY_MS * attempt); + } } const reason = lastError instanceof Error ? lastError.message : String(lastError); throw this.mask( new PublicDataApiError( - `${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`, + `${operation}: ${this.maxAttempts}회 시도 실패 — ${reason}`, ), ); } diff --git a/backend/src/modules/weather/kma-forecast.client.spec.ts b/backend/src/modules/weather/kma-forecast.client.spec.ts new file mode 100644 index 0000000..5fa91e0 --- /dev/null +++ b/backend/src/modules/weather/kma-forecast.client.spec.ts @@ -0,0 +1,108 @@ +import { + BudgetExceededError, + CallBudget, +} from '@/modules/place/sync/call-budget'; +import { + type FetchLike, + PublicDataApiError, +} from '@/modules/place/sync/public-data.client'; + +import { KmaForecastClient, parseKmaPage } from './kma-forecast.client'; + +const KEY = 'abc+def/ghi=='; + +const body = (resultCode: string, item?: unknown, totalCount = 1) => + JSON.stringify({ + response: { + header: { resultCode, resultMsg: 'X' }, + body: item === undefined ? undefined : { items: { item }, totalCount }, + }, + }); + +function client( + responses: Array<{ status?: number; body?: string; throws?: Error }>, +) { + const urls: string[] = []; + const fetchImpl: FetchLike = (url) => { + urls.push(url); + const next = responses.shift(); + if (!next) throw new Error('예상보다 많이 호출했다'); + if (next.throws) return Promise.reject(next.throws); + const status = next.status ?? 200; + return Promise.resolve({ + ok: status < 400, + status, + text: () => Promise.resolve(next.body ?? ''), + }); + }; + const api = new KmaForecastClient({ + serviceKey: KEY, + budget: new CallBudget(100), + fetchImpl, + sleep: () => Promise.resolve(), + }); + return { api, urls }; +} + +describe('parseKmaPage', () => { + it('정상(00)이면 항목을, 03(NODATA)이면 자료 없음을 돌려준다', () => { + const item = { + category: 'POP', + fcstDate: '20261109', + fcstTime: '1500', + fcstValue: '60', + }; + expect(parseKmaPage(body('00', [item], 1), 'getVilageFcst')).toEqual({ + items: [item], + totalCount: 1, + noData: false, + }); + expect(parseKmaPage(body('03'), 'getVilageFcst')).toEqual({ + items: [], + totalCount: 0, + noData: true, + }); + }); + + it('22 는 한도 초과, 나머지 코드는 오류다', () => { + expect(() => parseKmaPage(body('22'), 'getVilageFcst')).toThrow( + BudgetExceededError, + ); + expect(() => parseKmaPage(body('10'), 'getVilageFcst')).toThrow( + /getVilageFcst: 결과 코드 10/, + ); + }); +}); + +describe('KmaForecastClient', () => { + it('발표 시각·격자·JSON 을 실어 부른다', async () => { + const { api, urls } = client([{ body: body('00', [], 0) }]); + await api.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 }); + const url = new URL(urls[0]); + expect(url.pathname).toBe( + '/1360000/VilageFcstInfoService_2.0/getVilageFcst', + ); + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + serviceKey: KEY, + dataType: 'JSON', + base_date: '20261108', + base_time: '1100', + nx: '89', + ny: '90', + }); + }); + + it('입력 화면을 막지 않게 두 번까지만 시도하고, 오류에 키를 남기지 않는다', async () => { + const { api, urls } = client([ + { throws: new Error(`connect ETIMEDOUT ${KEY}`) }, + { throws: new Error('connect ETIMEDOUT') }, + ]); + const error = await api + .forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 }) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(PublicDataApiError); + expect((error as Error).message).toContain('2회 시도 실패'); + expect((error as Error).message).not.toContain(KEY); + expect(urls).toHaveLength(2); + }); +}); diff --git a/backend/src/modules/weather/kma-forecast.client.ts b/backend/src/modules/weather/kma-forecast.client.ts new file mode 100644 index 0000000..e6c77ba --- /dev/null +++ b/backend/src/modules/weather/kma-forecast.client.ts @@ -0,0 +1,102 @@ +/** + * 기상청 단기예보 조회서비스(공공데이터포털 VilageFcstInfoService_2.0) 호출. + * 공공데이터 공통 클라이언트(키 가리기·한도·재시도)를 쓰되, 사용자 요청 중에 부르므로 제한 시간을 짧게 둔다. + */ +import { BudgetExceededError } from '@/modules/place/sync/call-budget'; +import { + asRecord, + parseJsonOrThrow, + PublicDataApiError, + PublicDataClient, + type PublicDataClientOptions, + type PublicDataItem, + toItems, +} from '@/modules/place/sync/public-data.client'; + +import type { KmaGrid } from './kma-grid'; +import { issueParams, type KmaIssue } from './kma-forecast'; + +export const KMA_FORECAST_BASE_URL = + 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0'; +export const KMA_FORECAST_OPERATION = 'getVilageFcst'; + +/** 한 번에 받을 행 수 — 발표 하나가 약 850~1,000행이다(시간당 12개 요소 × 최대 4일) */ +const ROWS_PER_PAGE = 1500; +/** 입력 화면을 오래 막지 않게 짧게 두고, 한 번만 다시 시도한다 */ +const TIMEOUT_MS = 3_000; +const MAX_ATTEMPTS = 2; + +const RESULT_OK = '00'; +const RESULT_NO_DATA = '03'; +const RESULT_QUOTA_EXCEEDED = '22'; + +export interface KmaPage { + items: PublicDataItem[]; + totalCount: number; + /** 결과 코드 03(NODATA_ERROR) — 발표 직후라 아직 자료가 없을 때 온다 */ + noData: boolean; +} + +/** + * 응답 → 항목. `{ response: { header: { resultCode }, body: { items: { item }, totalCount } } }` 이고 정상 코드는 '00'. + * 03 은 오류가 아니라 '아직 자료 없음'으로 돌려준다(앞 발표로 다시 받는다). + */ +export function parseKmaPage(body: string, operation: string): KmaPage { + const response = asRecord( + asRecord(parseJsonOrThrow(body, operation))?.response, + ); + const header = asRecord(response?.header); + const code = typeof header?.resultCode === 'string' ? header.resultCode : ''; + if (code === RESULT_NO_DATA) + return { items: [], totalCount: 0, noData: true }; + if (code !== RESULT_OK) { + if (code === RESULT_QUOTA_EXCEEDED) { + throw new BudgetExceededError(operation, 'server'); + } + const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : ''; + throw new PublicDataApiError( + `${operation}: 결과 코드 ${code || '없음'} ${msg}`.trim(), + ); + } + const resBody = asRecord(response?.body); + const items = toItems(asRecord(resBody?.items)?.item); + const totalCount = Number(resBody?.totalCount ?? items.length); + return { + items, + totalCount: Number.isFinite(totalCount) ? totalCount : items.length, + noData: false, + }; +} + +/** 날씨 서비스가 쓰는 창구 — 테스트에서는 가짜로 바꿔 끼운다 */ +export interface KmaForecastApi { + forecast(issue: KmaIssue, grid: KmaGrid): Promise; +} + +export class KmaForecastClient + extends PublicDataClient + implements KmaForecastApi +{ + constructor(options: PublicDataClientOptions) { + super( + options, + KMA_FORECAST_BASE_URL, + { dataType: 'JSON' }, + { timeoutMs: TIMEOUT_MS, maxAttempts: MAX_ATTEMPTS }, + ); + } + + forecast(issue: KmaIssue, grid: KmaGrid): Promise { + return this.get(KMA_FORECAST_OPERATION, { + pageNo: 1, + numOfRows: ROWS_PER_PAGE, + ...issueParams(issue), + nx: grid.nx, + ny: grid.ny, + }); + } + + protected parse(body: string, operation: string): KmaPage { + return parseKmaPage(body, operation); + } +} diff --git a/backend/src/modules/weather/kma-forecast.spec.ts b/backend/src/modules/weather/kma-forecast.spec.ts new file mode 100644 index 0000000..2ac3518 --- /dev/null +++ b/backend/src/modules/weather/kma-forecast.spec.ts @@ -0,0 +1,242 @@ +import { + issuedAtIso, + issueParams, + type KmaIssue, + lastForecastDate, + latestIssue, + parseKmaItems, + previousIssue, + summarizeWindow, +} from './kma-forecast'; + +const kst = (iso: string) => new Date(`${iso}+09:00`); +const at = (hour: number) => hour * 60; + +/** 그날 hours 시각마다 강수확률·기온·하늘·강수 형태 항목을 만든다 */ +function items( + date: string, + hours: number[], + value: (hour: number) => { + pop: number; + tmp: number; + sky: number; + pty: number; + }, +) { + const out: Record[] = []; + for (const hour of hours) { + const v = value(hour); + const base = { + fcstDate: date.replace(/-/g, ''), + fcstTime: `${String(hour).padStart(2, '0')}00`, + }; + out.push({ ...base, category: 'POP', fcstValue: String(v.pop) }); + out.push({ ...base, category: 'TMP', fcstValue: String(v.tmp) }); + out.push({ ...base, category: 'SKY', fcstValue: String(v.sky) }); + out.push({ ...base, category: 'PTY', fcstValue: String(v.pty) }); + } + return out; +} + +const range = (from: number, to: number, step = 1) => + Array.from( + { length: Math.floor((to - from) / step) + 1 }, + (_, i) => from + i * step, + ); + +describe('latestIssue · previousIssue', () => { + it('발표 10분 뒤부터 그 발표를 쓴다', () => { + expect(latestIssue(kst('2026-11-08T11:09:00'))).toEqual({ + date: '2026-11-08', + hour: 8, + }); + expect(latestIssue(kst('2026-11-08T11:10:00'))).toEqual({ + date: '2026-11-08', + hour: 11, + }); + }); + + it('02:10 전에는 전날 23시 발표다', () => { + expect(latestIssue(kst('2026-11-08T02:09:00'))).toEqual({ + date: '2026-11-07', + hour: 23, + }); + expect(latestIssue(kst('2026-11-08T02:10:00'))).toEqual({ + date: '2026-11-08', + hour: 2, + }); + }); + + it('앞 발표 — 02시 앞은 전날 23시', () => { + expect(previousIssue({ date: '2026-11-08', hour: 11 })).toEqual({ + date: '2026-11-08', + hour: 8, + }); + expect(previousIssue({ date: '2026-11-01', hour: 2 })).toEqual({ + date: '2026-10-31', + hour: 23, + }); + }); +}); + +describe('issueParams · lastForecastDate · issuedAtIso', () => { + it('요청 값은 YYYYMMDD · HH00 이다', () => { + expect(issueParams({ date: '2026-11-08', hour: 2 })).toEqual({ + base_date: '20261108', + base_time: '0200', + }); + }); + + it('02~14시 발표는 글피(3일 뒤), 17~23시 발표는 그글피(4일 뒤)까지 나온다', () => { + expect(lastForecastDate({ date: '2026-11-08', hour: 14 })).toBe( + '2026-11-11', + ); + expect(lastForecastDate({ date: '2026-11-08', hour: 17 })).toBe( + '2026-11-12', + ); + }); + + it('발표 시각을 UTC 로 낸다', () => { + expect(issuedAtIso({ date: '2026-11-08', hour: 11 })).toBe( + '2026-11-08T02:00:00.000Z', + ); + expect(issuedAtIso({ date: '2026-11-08', hour: 2 })).toBe( + '2026-11-07T17:00:00.000Z', + ); + }); +}); + +describe('parseKmaItems', () => { + it('쓰는 요소만 시각별로 모으고 결측값·형식이 다른 값은 버린다', () => { + const forecast = parseKmaItems([ + { + category: 'TMP', + fcstDate: '20261109', + fcstTime: '1500', + fcstValue: '14', + }, + { + category: 'POP', + fcstDate: '20261109', + fcstTime: '1500', + fcstValue: '60', + }, + { + category: 'REH', + fcstDate: '20261109', + fcstTime: '1500', + fcstValue: '80', + }, + { + category: 'POP', + fcstDate: '20261109', + fcstTime: '1400', + fcstValue: '30', + }, + { + category: 'TMP', + fcstDate: '20261109', + fcstTime: '1400', + fcstValue: '-999', + }, + { + category: 'SKY', + fcstDate: '2026119', + fcstTime: '1400', + fcstValue: '4', + }, + ]); + expect(forecast).toEqual({ + '2026-11-09': [ + { minute: at(14), pop: 30, tmp: null, sky: null, pty: null }, + { minute: at(15), pop: 60, tmp: 14, sky: null, pty: null }, + ], + }); + }); +}); + +describe('summarizeWindow', () => { + const issue: KmaIssue = { date: '2026-11-08', hour: 11 }; + + it('시간 범위 안의 가장 높은 강수확률과 그때의 강수 형태, 기온 범위, 가장 잦은 하늘을 낸다', () => { + const forecast = parseKmaItems([ + ...items('2026-11-09', range(0, 23), (h) => ({ + pop: h === 17 ? 70 : h === 21 ? 90 : 20, + tmp: 10 + (h % 6), + sky: h < 16 ? 3 : 4, + pty: h === 17 ? 1 : h === 21 ? 3 : 0, + })), + ]); + expect( + summarizeWindow(forecast, issue, '2026-11-09', at(14), at(20)), + ).toEqual({ + date: '2026-11-09', + // 21시 90% 는 범위 밖이다 + precipitationProbability: 70, + precipitationType: 'rain', + // 14·15시 구름많음, 16~19시 흐림 + sky: 'cloudy', + temperatureMin: 10, + temperatureMax: 15, + issuedAt: '2026-11-08T02:00:00.000Z', + }); + }); + + it('3시간 간격 예보는 한 시각이 3시간을 맡는다(12시 값이 14시를 덮는다)', () => { + const forecast = parseKmaItems([ + ...items('2026-11-11', range(0, 21, 3), (h) => ({ + pop: h === 12 ? 60 : 10, + tmp: 12, + sky: 1, + pty: 0, + })), + ...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })), + ]); + const summary = summarizeWindow( + forecast, + issue, + '2026-11-11', + at(14), + at(20), + ); + expect(summary?.precipitationProbability).toBe(60); + expect(summary?.sky).toBe('clear'); + }); + + it('예보가 시간 범위 끝까지 닿지 않으면 null', () => { + const forecast = parseKmaItems([ + // 글피 다음 날은 0시 값만 온다 + ...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })), + ...items('2026-11-10', range(0, 18), () => ({ + pop: 0, + tmp: 8, + sky: 1, + pty: 0, + })), + ]); + expect( + summarizeWindow(forecast, issue, '2026-11-12', at(14), at(20)), + ).toBeNull(); + expect( + summarizeWindow(forecast, issue, '2026-11-10', at(14), at(20)), + ).toBeNull(); + expect( + summarizeWindow(forecast, issue, '2026-11-13', at(14), at(20)), + ).toBeNull(); + }); + + it('발표 당일은 이미 지난 시각의 예보가 없어도 된다', () => { + const forecast = parseKmaItems([ + ...items('2026-11-08', range(12, 23), () => ({ + pop: 30, + tmp: 15, + sky: 1, + pty: 0, + })), + ]); + expect( + summarizeWindow(forecast, issue, '2026-11-08', at(11), at(17)) + ?.precipitationProbability, + ).toBe(30); + }); +}); diff --git a/backend/src/modules/weather/kma-forecast.ts b/backend/src/modules/weather/kma-forecast.ts new file mode 100644 index 0000000..1f001de --- /dev/null +++ b/backend/src/modules/weather/kma-forecast.ts @@ -0,0 +1,252 @@ +/** + * 기상청 단기예보(getVilageFcst) 발표 시각 고르기·응답 해석·시간 범위 요약 (순수 함수). + * 매뉴얼 검토: docs/research/data-openapi-review/store_and_weather.md §4·§5. 설계: docs/design/ai-course.md §3.3 + */ +import type { PublicDataItem } from '@/modules/place/sync/public-data.client'; +import { addDaysIso } from '@/shared/utils/iso-date'; +import { kstClock } from '@/shared/utils/kst'; + +/** 단기예보 발표 시각(시, 한국 시간) — 하루 8번 */ +export const KMA_ISSUE_HOURS: readonly number[] = [2, 5, 8, 11, 14, 17, 20, 23]; +/** 발표 뒤 이만큼(분) 지나야 받을 수 있다 */ +const AVAILABLE_AFTER_MINUTES = 10; +/** 이 시각 이후 발표는 그글피(4일 뒤)까지, 앞의 발표는 글피(3일 뒤)까지 나온다 */ +const LONG_RANGE_FROM_HOUR = 17; +const SHORT_RANGE_DAYS = 3; +const LONG_RANGE_DAYS = 4; +/** 기상청 결측값 — 절댓값이 이보다 크면 버린다(매뉴얼: +900 이상, -900 이하) */ +const MISSING_VALUE_ABS = 900; +const MINUTES_PER_HOUR = 60; +const MINUTES_PER_DAY = 1440; +const KST_OFFSET_HOURS = 9; + +/** 단기예보 발표 하나 — 한국 날짜 'YYYY-MM-DD' 와 시 */ +export interface KmaIssue { + date: string; + hour: number; +} + +/** 지금 받을 수 있는 가장 최근 발표 */ +export function latestIssue(now: Date): KmaIssue { + const { date, minutes } = kstClock(now); + for (let i = KMA_ISSUE_HOURS.length - 1; i >= 0; i--) { + const hour = KMA_ISSUE_HOURS[i]; + if (minutes >= hour * MINUTES_PER_HOUR + AVAILABLE_AFTER_MINUTES) { + return { date, hour }; + } + } + return previousIssue({ date, hour: KMA_ISSUE_HOURS[0] }); +} + +/** 바로 앞 발표 — 최근 발표 자료가 아직 없을 때(발표가 늦어질 때) 쓴다 */ +export function previousIssue(issue: KmaIssue): KmaIssue { + const index = KMA_ISSUE_HOURS.indexOf(issue.hour); + if (index > 0) return { date: issue.date, hour: KMA_ISSUE_HOURS[index - 1] }; + return { + date: addDaysIso(issue.date, -1) ?? issue.date, + hour: KMA_ISSUE_HOURS[KMA_ISSUE_HOURS.length - 1], + }; +} + +/** API 요청 값 base_date('YYYYMMDD')·base_time('HH00') */ +export function issueParams(issue: KmaIssue): { + base_date: string; + base_time: string; +} { + return { + base_date: issue.date.replace(/-/g, ''), + base_time: `${String(issue.hour).padStart(2, '0')}00`, + }; +} + +/** 그 발표가 예보를 내는 마지막 날 — 02~14시 발표는 글피, 17~23시 발표는 그글피 */ +export function lastForecastDate(issue: KmaIssue): string { + const days = + issue.hour >= LONG_RANGE_FROM_HOUR ? LONG_RANGE_DAYS : SHORT_RANGE_DAYS; + return addDaysIso(issue.date, days) ?? issue.date; +} + +/** 발표 시각(ISO 8601 UTC) — API 규약에 맞춰 UTC 로 낸다 */ +export function issuedAtIso(issue: KmaIssue): string { + const [y, m, d] = issue.date.split('-').map(Number); + return new Date( + Date.UTC(y, m - 1, d, issue.hour - KST_OFFSET_HOURS), + ).toISOString(); +} + +/** 예보 시각 하나의 값. 받지 못한 요소는 null */ +export interface ForecastPoint { + /** 그날 00:00 부터의 분 */ + minute: number; + /** 강수확률(%) */ + pop: number | null; + /** 기온(℃) */ + tmp: number | null; + /** 하늘 상태 코드 — 1 맑음 · 3 구름많음 · 4 흐림 */ + sky: number | null; + /** 강수 형태 코드 — 0 없음 · 1 비 · 2 비/눈 · 3 눈 · 4 소나기 */ + pty: number | null; +} + +/** 날짜('YYYY-MM-DD')별 예보 시각(시각 순) */ +export type KmaForecast = Record; + +const CATEGORY_FIELDS: Readonly< + Record> +> = { + POP: 'pop', + TMP: 'tmp', + SKY: 'sky', + PTY: 'pty', +}; + +/** 응답 값(문자열·숫자로 온다)을 글자로. 다른 형식이면 빈 문자열 */ +function text(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' + ? String(value) + : ''; +} + +/** + * 응답 항목({category, fcstDate 'YYYYMMDD', fcstTime 'HHMM', fcstValue}) → 날짜별 예보 시각. + * 쓰는 요소(강수확률·기온·하늘·강수 형태)만 남기고, 결측값·형식이 다른 값은 버린다. + */ +export function parseKmaItems(items: PublicDataItem[]): KmaForecast { + const byDate = new Map>(); + for (const item of items) { + const field = CATEGORY_FIELDS[text(item.category)]; + const date = /^(\d{4})(\d{2})(\d{2})$/.exec(text(item.fcstDate)); + const time = /^(\d{2})(\d{2})$/.exec(text(item.fcstTime)); + const value = Number(text(item.fcstValue) || Number.NaN); + if (!field || !date || !time || !Number.isFinite(value)) continue; + if (Math.abs(value) >= MISSING_VALUE_ABS) continue; + const day = `${date[1]}-${date[2]}-${date[3]}`; + const minute = Number(time[1]) * MINUTES_PER_HOUR + Number(time[2]); + const points = byDate.get(day) ?? new Map(); + const point = points.get(minute) ?? { + minute, + pop: null, + tmp: null, + sky: null, + pty: null, + }; + point[field] = value; + points.set(minute, point); + byDate.set(day, points); + } + const out: KmaForecast = {}; + for (const [day, points] of byDate) { + out[day] = [...points.values()].sort((a, b) => a.minute - b.minute); + } + return out; +} + +export type PrecipitationType = + | 'none' + | 'rain' + | 'rain_snow' + | 'snow' + | 'shower'; +export type SkyState = 'clear' | 'partly_cloudy' | 'cloudy'; + +const PRECIPITATION_TYPES: Readonly> = { + 0: 'none', + 1: 'rain', + 2: 'rain_snow', + 3: 'snow', + 4: 'shower', +}; +const SKY_STATES: Readonly> = { + 1: 'clear', + 3: 'partly_cloudy', + 4: 'cloudy', +}; + +/** 코스 시간 범위의 예보 요약 — 입력 화면("토요일 오후 비 60%")과 결과의 날씨에 쓴다(설계 §3.7) */ +export interface WindowForecast { + date: string; + /** 시간 범위 중 가장 높은 강수확률(%) — 40% 이상이면 비 조건(prefersIndoor) */ + precipitationProbability: number; + /** 강수확률이 가장 높은 때의 강수 형태 */ + precipitationType: PrecipitationType; + /** 시간 범위에서 가장 잦은 하늘 상태(같으면 더 흐린 쪽) */ + sky: SkyState | null; + temperatureMin: number | null; + temperatureMax: number | null; + /** 발표 시각(ISO 8601 UTC) — 화면에 "11시 발표"로 보인다 */ + issuedAt: string; +} + +/** 예보 시각마다 그 값이 맡는 구간 [minute, 다음 예보 시각) — 마지막 시각은 다음 날 0시나 앞 간격만큼 */ +function intervals( + points: ForecastPoint[], + nextDayFirst: ForecastPoint | undefined, +): { point: ForecastPoint; from: number; to: number }[] { + return points.map((point, i) => { + const next = points[i + 1]?.minute; + let to: number; + if (next !== undefined) { + to = next; + } else if (nextDayFirst?.minute === 0) { + to = MINUTES_PER_DAY; + } else { + const gap = + i > 0 ? point.minute - points[i - 1].minute : MINUTES_PER_HOUR; + to = Math.min(point.minute + gap, MINUTES_PER_DAY); + } + return { point, from: point.minute, to }; + }); +} + +/** + * 그날 [start, end) 의 예보 요약. 예보가 시간 범위 끝까지 닿지 않으면 null(입력 화면에 '예보 없음'). + * 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다. 발표 당일은 이미 지난 시각의 예보가 없어도 된다. + */ +export function summarizeWindow( + forecast: KmaForecast, + issue: KmaIssue, + date: string, + start: number, + end: number, +): WindowForecast | null { + const points = forecast[date] ?? []; + if (points.length === 0 || end <= start) return null; + const nextDay = addDaysIso(date, 1); + const spans = intervals(points, nextDay ? forecast[nextDay]?.[0] : undefined); + const covered = spans.filter((s) => s.from < end && start < s.to); + if (covered.length === 0) return null; + if (spans[spans.length - 1].to < end) return null; + if (date !== issue.date && spans[0].from > start) return null; + + const withPop = covered.filter((s) => s.point.pop !== null); + if (withPop.length === 0) return null; + const wettest = withPop.reduce((a, b) => + (b.point.pop ?? 0) > (a.point.pop ?? 0) ? b : a, + ); + const temps = covered + .map((s) => s.point.tmp) + .filter((t): t is number => t !== null); + const skyCounts = new Map(); + for (const s of covered) { + if (s.point.sky !== null) { + skyCounts.set(s.point.sky, (skyCounts.get(s.point.sky) ?? 0) + 1); + } + } + let sky: number | null = null; + for (const [code, count] of skyCounts) { + const best = sky === null ? 0 : (skyCounts.get(sky) ?? 0); + if (count > best || (count === best && sky !== null && code > sky)) { + sky = code; + } + } + + return { + date, + precipitationProbability: wettest.point.pop ?? 0, + precipitationType: PRECIPITATION_TYPES[wettest.point.pty ?? 0] ?? 'none', + sky: sky === null ? null : (SKY_STATES[sky] ?? null), + temperatureMin: temps.length > 0 ? Math.min(...temps) : null, + temperatureMax: temps.length > 0 ? Math.max(...temps) : null, + issuedAt: issuedAtIso(issue), + }; +} diff --git a/backend/src/modules/weather/kma-grid.spec.ts b/backend/src/modules/weather/kma-grid.spec.ts new file mode 100644 index 0000000..1761e56 --- /dev/null +++ b/backend/src/modules/weather/kma-grid.spec.ts @@ -0,0 +1,11 @@ +import { toKmaGrid } from './kma-grid'; + +describe('toKmaGrid', () => { + it('서울시청은 기상청 격자 (60, 127) 이다', () => { + expect(toKmaGrid(37.5665, 126.978)).toEqual({ nx: 60, ny: 127 }); + }); + + it('대구 도심은 (89, 90) 이다(2026-10-29 실제 호출과 같다)', () => { + expect(toKmaGrid(35.87021, 128.59409)).toEqual({ nx: 89, ny: 90 }); + }); +}); diff --git a/backend/src/modules/weather/kma-grid.ts b/backend/src/modules/weather/kma-grid.ts new file mode 100644 index 0000000..da6944f --- /dev/null +++ b/backend/src/modules/weather/kma-grid.ts @@ -0,0 +1,45 @@ +/** + * 위도·경도 → 기상청 단기예보 격자(nx, ny) (순수 함수). + * 기상청이 공개한 람베르트 정각원추도법(LCC) 공식이다. 2026-10-29 격자표 3,801행과 대조해 98.9% 가 정확히 맞고 + * 나머지 43행도 1칸 차이였다(docs/research/data-openapi-review/store_and_weather.md §6). + */ + +/** 지구 반지름(km)과 격자 간격(km) */ +const EARTH_RADIUS_KM = 6371.00877; +const GRID_SPACING_KM = 5.0; +/** 표준 위도 1·2, 기준점 경도·위도 */ +const STANDARD_LAT_1 = 30.0; +const STANDARD_LAT_2 = 60.0; +const ORIGIN_LNG = 126.0; +const ORIGIN_LAT = 38.0; +/** 기준점의 격자 좌표 */ +const ORIGIN_X = 43; +const ORIGIN_Y = 136; + +const DEG = Math.PI / 180; +const QUARTER_PI = Math.PI / 4; + +export interface KmaGrid { + nx: number; + ny: number; +} + +export function toKmaGrid(lat: number, lng: number): KmaGrid { + const re = EARTH_RADIUS_KM / GRID_SPACING_KM; + const s1 = STANDARD_LAT_1 * DEG; + const s2 = STANDARD_LAT_2 * DEG; + const sn = + Math.log(Math.cos(s1) / Math.cos(s2)) / + Math.log(Math.tan(QUARTER_PI + s2 / 2) / Math.tan(QUARTER_PI + s1 / 2)); + const sf = (Math.tan(QUARTER_PI + s1 / 2) ** sn * Math.cos(s1)) / sn; + const ro = (re * sf) / Math.tan(QUARTER_PI + (ORIGIN_LAT * DEG) / 2) ** sn; + const ra = (re * sf) / Math.tan(QUARTER_PI + (lat * DEG) / 2) ** sn; + let theta = lng * DEG - ORIGIN_LNG * DEG; + if (theta > Math.PI) theta -= 2 * Math.PI; + if (theta < -Math.PI) theta += 2 * Math.PI; + theta *= sn; + return { + nx: Math.floor(ra * Math.sin(theta) + ORIGIN_X + 0.5), + ny: Math.floor(ro - ra * Math.cos(theta) + ORIGIN_Y + 0.5), + }; +} diff --git a/backend/src/modules/weather/weather.module.ts b/backend/src/modules/weather/weather.module.ts new file mode 100644 index 0000000..889b52a --- /dev/null +++ b/backend/src/modules/weather/weather.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; + +import { + createKmaApi, + KMA_API_FACTORY, + WeatherService, +} from './weather.service'; + +/** + * 날씨(기상청 단기예보). AI 코스 조건 해석·생성(설계 §3.3)이 WeatherService 를 쓴다. + * 설정(ConfigModule)과 캐시(CacheModule)는 앱 전역 모듈을 쓴다. + */ +@Module({ + providers: [ + WeatherService, + { provide: KMA_API_FACTORY, useValue: createKmaApi }, + ], + exports: [WeatherService], +}) +export class WeatherModule {} diff --git a/backend/src/modules/weather/weather.service.spec.ts b/backend/src/modules/weather/weather.service.spec.ts new file mode 100644 index 0000000..6301b1d --- /dev/null +++ b/backend/src/modules/weather/weather.service.spec.ts @@ -0,0 +1,166 @@ +import type { ConfigService } from '@nestjs/config'; +import type { Cache } from 'cache-manager'; + +import type { CallBudget } from '@/modules/place/sync/call-budget'; +import { PublicDataApiError } from '@/modules/place/sync/public-data.client'; + +import type { KmaForecastApi, KmaPage } from './kma-forecast.client'; +import type { KmaIssue } from './kma-forecast'; +import type { KmaGrid } from './kma-grid'; +import { WeatherService } from './weather.service'; + +const DOWNTOWN = { lat: 35.87021, lng: 128.59409 }; +/** 2026-11-08(일) 11:30 KST — 최근 발표는 11시, 예보는 글피(11-11)까지 */ +const NOW = new Date('2026-11-08T11:30:00+09:00'); +const at = (hour: number) => hour * 60; + +/** 11-08 ~ 11-11 매시, 11-12 0시의 강수확률이 pop 인 응답 */ +function page(pop: number): KmaPage { + const items: Record[] = []; + const dates = ['20261108', '20261109', '20261110', '20261111', '20261112']; + for (const date of dates) { + for (let h = 0; h < 24; h++) { + if (date === '20261112' && h > 0) break; + const base = { + fcstDate: date, + fcstTime: `${String(h).padStart(2, '0')}00`, + }; + items.push({ ...base, category: 'POP', fcstValue: String(pop) }); + items.push({ ...base, category: 'TMP', fcstValue: '12' }); + items.push({ ...base, category: 'SKY', fcstValue: '4' }); + items.push({ ...base, category: 'PTY', fcstValue: pop > 0 ? '1' : '0' }); + } + } + return { items, totalCount: items.length, noData: false }; +} + +const NO_DATA: KmaPage = { items: [], totalCount: 0, noData: true }; + +function memoryCache(): Cache { + const store = new Map(); + return { + get: (key: string) => Promise.resolve(store.get(key)), + set: (key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(value); + }, + } as unknown as Cache; +} + +function setup( + respond: (issue: KmaIssue, grid: KmaGrid) => Promise, + key = 'test-key', +) { + const calls: { issue: KmaIssue; grid: KmaGrid }[] = []; + const api: KmaForecastApi = { + forecast: (issue, grid) => { + calls.push({ issue, grid }); + return respond(issue, grid); + }, + }; + const budgets: CallBudget[] = []; + const config = { get: () => key } as unknown as ConfigService; + const service = new WeatherService(config, memoryCache(), (_key, budget) => { + budgets.push(budget); + return api; + }); + return { service, calls, budgets }; +} + +describe('WeatherService.forecastWindow', () => { + it('최근 발표를 격자로 불러 시간 범위를 요약하고, 같은 격자·발표는 다시 부르지 않는다', async () => { + const { service, calls } = setup(() => Promise.resolve(page(60))); + const first = await service.forecastWindow( + DOWNTOWN, + '2026-11-09', + at(14), + at(20), + NOW, + ); + expect(first).toMatchObject({ + date: '2026-11-09', + precipitationProbability: 60, + precipitationType: 'rain', + sky: 'cloudy', + issuedAt: '2026-11-08T02:00:00.000Z', + }); + await service.forecastWindow(DOWNTOWN, '2026-11-10', at(10), at(16), NOW); + expect(calls).toEqual([ + { issue: { date: '2026-11-08', hour: 11 }, grid: { nx: 89, ny: 90 } }, + ]); + }); + + it('최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다', async () => { + const { service, calls } = setup((issue) => + Promise.resolve(issue.hour === 11 ? NO_DATA : page(10)), + ); + const summary = await service.forecastWindow( + DOWNTOWN, + '2026-11-09', + at(14), + at(20), + NOW, + ); + expect(summary?.issuedAt).toBe('2026-11-07T23:00:00.000Z'); + expect(calls.map((c) => c.issue.hour)).toEqual([11, 8]); + }); + + it('기상청 장애면 null 이고, 잠깐 동안은 다시 부르지 않는다', async () => { + const { service, calls } = setup(() => + Promise.reject(new PublicDataApiError('getVilageFcst: HTTP 502')), + ); + for (let i = 0; i < 2; i++) { + expect( + await service.forecastWindow( + DOWNTOWN, + '2026-11-09', + at(14), + at(20), + NOW, + ), + ).toBeNull(); + } + expect(calls).toHaveLength(1); + }); + + it('지난 날짜·예보가 닿지 않는 날짜·한국 밖은 부르지 않고 null', async () => { + const { service, calls } = setup(() => Promise.resolve(page(0))); + const window = [at(14), at(20), NOW] as const; + expect( + await service.forecastWindow(DOWNTOWN, '2026-11-07', ...window), + ).toBeNull(); + // 11시 발표는 글피(11-11)까지다 + expect( + await service.forecastWindow(DOWNTOWN, '2026-11-12', ...window), + ).toBeNull(); + expect( + await service.forecastWindow({ lat: 0, lng: 0 }, '2026-11-09', ...window), + ).toBeNull(); + expect(calls).toHaveLength(0); + }); + + it('키가 없으면 부르지 않고 null', async () => { + const { service, calls, budgets } = setup( + () => Promise.resolve(page(0)), + '', + ); + expect( + await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW), + ).toBeNull(); + expect(calls).toHaveLength(0); + expect(budgets).toHaveLength(0); + }); + + it('한국 날짜가 바뀌면 하루 호출 수를 새로 센다', async () => { + const { service, budgets } = setup(() => Promise.resolve(page(0))); + await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW); + await service.forecastWindow( + DOWNTOWN, + '2026-11-10', + at(14), + at(20), + new Date('2026-11-09T09:00:00+09:00'), + ); + expect(budgets).toHaveLength(2); + }); +}); diff --git a/backend/src/modules/weather/weather.service.ts b/backend/src/modules/weather/weather.service.ts new file mode 100644 index 0000000..4377c7b --- /dev/null +++ b/backend/src/modules/weather/weather.service.ts @@ -0,0 +1,167 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Cache } from 'cache-manager'; + +import { CallBudget } from '@/modules/place/sync/call-budget'; +import { isInKorea, type LatLng } from '@/shared/utils/geo'; +import { kstDateString } from '@/shared/utils/kst'; + +import { KmaForecastClient, type KmaForecastApi } from './kma-forecast.client'; +import { + type KmaForecast, + type KmaIssue, + lastForecastDate, + latestIssue, + parseKmaItems, + previousIssue, + summarizeWindow, + type WindowForecast, +} from './kma-forecast'; +import { type KmaGrid, toKmaGrid } from './kma-grid'; + +/** 기상청 클라이언트를 만든다 — 테스트에서는 가짜로 바꿔 끼운다 */ +export const KMA_API_FACTORY = Symbol('KMA_API_FACTORY'); +export type KmaApiFactory = ( + serviceKey: string, + budget: CallBudget, +) => KmaForecastApi; + +export const createKmaApi: KmaApiFactory = (serviceKey, budget) => + new KmaForecastClient({ serviceKey, budget }); + +const SERVICE_KEY_NAME = 'PUBLIC_DATA_SERVICE_KEY'; +/** 격자·발표 시각별 예보를 이만큼(ms) 다시 부르지 않는다(설계 §3.3) */ +const FORECAST_TTL_MS = 60 * 60 * 1000; +/** 실패·자료 없음은 짧게 기억한다 — 장애 때 요청마다 제한 시간만큼 기다리지 않게 */ +const FAILURE_TTL_MS = 2 * 60 * 1000; +/** + * 하루 호출 상한(한국 시간 자정에 다시 센다) — 캐시가 있어 실제로는 하루 수십 번이다. + * 공공데이터포털 개발 계정 한도보다 낮게 두어 키 오류·버그로 한도를 다 쓰지 않게 한다. + */ +const DAILY_CALL_LIMIT = 1_000; + +type CachedForecast = + | { kind: 'ok'; forecast: KmaForecast } + | { kind: 'no_data' } + | { kind: 'failed' }; + +function cacheKey(grid: KmaGrid, issue: KmaIssue): string { + return `weather:kma:${grid.nx},${grid.ny}:${issue.date}T${issue.hour}`; +} + +/** + * 코스 날짜·지역의 단기예보(설계 §3.3). 입력 화면을 막지 않도록 **실패하면 던지지 않고 null** 을 돌려준다 + * (키 없음 · 예보가 없는 날짜 · 한국 밖 · 기상청 장애). null 이면 화면에 '예보 없음'을 보이고 비 조건은 쓰지 않는다. + */ +@Injectable() +export class WeatherService { + private readonly logger = new Logger(WeatherService.name); + private daily: { date: string; api: KmaForecastApi } | null = null; + private readonly inflight = new Map>(); + private warnedMissingKey = false; + + constructor( + private readonly config: ConfigService, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KMA_API_FACTORY) private readonly createApi: KmaApiFactory, + ) {} + + /** + * 그날 [start, end)(00:00 부터 분) 의 예보 요약. 지난 날짜, 예보가 닿지 않는 날짜(4일 뒤 이후)는 null. + * @param now 지금 시각 — 발표 시각을 고른다(테스트에서 고정) + */ + async forecastWindow( + point: LatLng, + date: string, + start: number, + end: number, + now: Date = new Date(), + ): Promise { + if (date < kstDateString(now)) return null; + if (!isInKorea(point.lat, point.lng)) return null; + const latest = latestIssue(now); + if (date > lastForecastDate(latest)) return null; + const grid = toKmaGrid(point.lat, point.lng); + // 최근 발표 자료가 아직 없으면(발표가 늦어질 때) 앞 발표를 쓴다 + for (const issue of [latest, previousIssue(latest)]) { + const cached = await this.load(grid, issue, now); + if (cached.kind === 'failed') return null; + if (cached.kind === 'ok') { + return summarizeWindow(cached.forecast, issue, date, start, end); + } + } + return null; + } + + private async load( + grid: KmaGrid, + issue: KmaIssue, + now: Date, + ): Promise { + const key = cacheKey(grid, issue); + const cached = await this.cache.get(key); + if (cached) return cached; + // 같은 격자를 동시에 물으면 한 번만 부른다 + let pending = this.inflight.get(key); + if (!pending) { + pending = this.fetch(grid, issue, now).finally(() => + this.inflight.delete(key), + ); + this.inflight.set(key, pending); + } + const result = await pending; + await this.cache.set( + key, + result, + result.kind === 'ok' ? FORECAST_TTL_MS : FAILURE_TTL_MS, + ); + return result; + } + + private async fetch( + grid: KmaGrid, + issue: KmaIssue, + now: Date, + ): Promise { + const api = this.api(now); + if (!api) return { kind: 'failed' }; + try { + const page = await api.forecast(issue, grid); + if (page.noData) return { kind: 'no_data' }; + if (page.totalCount > page.items.length) { + this.logger.warn( + `기상청 단기예보가 잘려서 왔다(격자 ${grid.nx},${grid.ny} · ${page.items.length}/${page.totalCount}행)`, + ); + } + return { kind: 'ok', forecast: parseKmaItems(page.items) }; + } catch (error) { + // 클라이언트가 오류 메시지의 키를 가린다. 사용자 위치 대신 5km 격자만 남긴다 + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn( + `기상청 단기예보를 받지 못했다(격자 ${grid.nx},${grid.ny} · ${issue.date} ${issue.hour}시 발표): ${reason}`, + ); + return { kind: 'failed' }; + } + } + + /** 오늘 쓸 클라이언트 — 한국 날짜가 바뀌면 하루 호출 수를 다시 센다. 키가 없으면 null */ + private api(now: Date): KmaForecastApi | null { + const today = kstDateString(now); + if (this.daily?.date === today) return this.daily.api; + const key = this.config.get(SERVICE_KEY_NAME)?.trim(); + if (!key || /change[-_]?me/i.test(key)) { + if (!this.warnedMissingKey) { + // 키 값은 어떤 경우에도 남기지 않는다 + this.logger.warn( + `${SERVICE_KEY_NAME} 가 없어 날씨 예보를 쓰지 않는다(입력 화면은 '예보 없음')`, + ); + this.warnedMissingKey = true; + } + return null; + } + const budget = new CallBudget(DAILY_CALL_LIMIT); + this.daily = { date: today, api: this.createApi(key, budget) }; + return this.daily.api; + } +} diff --git a/backend/src/shared/utils/kr-holidays.spec.ts b/backend/src/shared/utils/kr-holidays.spec.ts new file mode 100644 index 0000000..88832a9 --- /dev/null +++ b/backend/src/shared/utils/kr-holidays.spec.ts @@ -0,0 +1,131 @@ +import { + closedOnHoliday, + HOLIDAY_TABLE_YEARS, + holidayOn, + holidaysIn, + isHolidayTableYear, +} from './kr-holidays'; + +/** 'YYYY-MM-DD' 의 요일(0=일요일) */ +const weekday = (date: string) => new Date(`${date}T00:00:00Z`).getUTCDay(); +const addDays = (date: string, days: number) => + new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000) + .toISOString() + .slice(0, 10); + +/** 토·일요일과 겹치면 대체공휴일이 붙는 공휴일(현충일·신정·선거일은 붙지 않는다) */ +const SUBSTITUTED = [ + '삼일절', + '노동절', + '어린이날', + '부처님오신날', + '제헌절', + '광복절', + '개천절', + '한글날', + '성탄절', +]; + +describe('holidayOn', () => { + it('설·추석 연휴와 대체공휴일을 담는다', () => { + expect(holidayOn('2026-02-17')).toEqual({ name: '설날', kind: 'seollal' }); + expect(holidayOn('2026-09-26')?.kind).toBe('chuseok'); + // 2027 설날(2월 7일)이 일요일이라 2월 9일이 대체공휴일이다 — 설 연휴로 본다 + expect(holidayOn('2027-02-09')).toEqual({ + name: '대체공휴일(설날)', + kind: 'seollal', + }); + expect(holidayOn('2026-10-05')?.name).toBe('대체공휴일(개천절)'); + }); + + it('2026년부터 노동절·제헌절이 공휴일이고, 주말과 겹치면 대체공휴일이 있다', () => { + expect(holidayOn('2026-05-01')?.name).toBe('노동절'); + expect(holidayOn('2026-07-17')?.name).toBe('제헌절'); + expect(holidayOn('2027-05-03')?.name).toBe('대체공휴일(노동절)'); + expect(holidayOn('2027-07-19')?.name).toBe('대체공휴일(제헌절)'); + }); + + it('현충일은 주말과 겹쳐도 대체공휴일이 없고, 선거일은 공휴일이다', () => { + expect(holidayOn('2026-06-06')?.name).toBe('현충일'); + expect(holidayOn('2026-06-08')).toBeNull(); + expect(holidayOn('2026-06-03')?.name).toBe('전국동시지방선거'); + }); + + it('평일·주말은 null 이다', () => { + expect(holidayOn('2026-11-09')).toBeNull(); + expect(holidayOn('2026-11-08')).toBeNull(); + }); + + it('표가 담은 해만 공휴일 여부를 안다', () => { + expect(HOLIDAY_TABLE_YEARS).toEqual([2026, 2027]); + expect(isHolidayTableYear('2027-12-31')).toBe(true); + expect(isHolidayTableYear('2028-01-01')).toBe(false); + // 공식 발표와 같은 개수(2026년 22일 · 2027년 24일, 주말과 겹친 날 포함) + expect(holidaysIn(2026)).toHaveLength(22); + expect(holidaysIn(2027)).toHaveLength(24); + }); + + it('표의 날짜는 실제 있는 날이다', () => { + for (const year of HOLIDAY_TABLE_YEARS) { + for (const { date } of holidaysIn(year)) { + expect(addDays(date, 0)).toBe(date); + } + } + }); + + it('주말과 겹친 공휴일은 그다음 첫 평일(다른 공휴일이 아닌 날)이 대체공휴일이다', () => { + for (const year of HOLIDAY_TABLE_YEARS) { + for (const { date, name } of holidaysIn(year)) { + if (!SUBSTITUTED.includes(name)) continue; + if (weekday(date) !== 0 && weekday(date) !== 6) continue; + const substituteName = `대체공휴일(${name})`; + let expected = addDays(date, 1); + while ( + weekday(expected) === 0 || + weekday(expected) === 6 || + (holidayOn(expected) && holidayOn(expected)?.name !== substituteName) + ) { + expected = addDays(expected, 1); + } + expect(holidayOn(expected)?.name).toBe(substituteName); + } + } + }); + + it('설·추석 연휴가 일요일과 겹치면 연휴 다음 첫 평일이 대체공휴일이다', () => { + for (const year of HOLIDAY_TABLE_YEARS) { + for (const [kind, label] of [ + ['seollal', '설날'], + ['chuseok', '추석'], + ] as const) { + const days = holidaysIn(year).filter( + (h) => h.kind === kind && !h.name.startsWith('대체공휴일'), + ); + const substitute = holidaysIn(year).find( + (h) => h.name === `대체공휴일(${label})`, + ); + const overlapsSunday = days.some((h) => weekday(h.date) === 0); + expect(Boolean(substitute)).toBe(overlapsSunday); + if (substitute) { + expect(substitute.date).toBe(addDays(days[days.length - 1].date, 1)); + } + } + } + }); +}); + +describe('closedOnHoliday', () => { + it("'공휴일' 휴무는 모든 공휴일에 쉰다", () => { + expect(closedOnHoliday(['공휴일'], 'public')).toBe(true); + expect(closedOnHoliday(['공휴일'], 'seollal')).toBe(true); + }); + + it('설·추석·신정 휴무는 그 명절에만 쉰다', () => { + expect(closedOnHoliday(['설', '추석'], 'seollal')).toBe(true); + expect(closedOnHoliday(['설', '추석'], 'chuseok')).toBe(true); + expect(closedOnHoliday(['설', '추석'], 'public')).toBe(false); + expect(closedOnHoliday(['1월 1일'], 'new_year')).toBe(true); + expect(closedOnHoliday(['1월 1일'], 'public')).toBe(false); + expect(closedOnHoliday([], 'seollal')).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/kr-holidays.ts b/backend/src/shared/utils/kr-holidays.ts new file mode 100644 index 0000000..3ec97fa --- /dev/null +++ b/backend/src/shared/utils/kr-holidays.ts @@ -0,0 +1,118 @@ +/** + * 대한민국 관공서 공휴일 표 (순수 데이터·함수) — AI 코스 거르기에서 명절·공휴일 휴무를 본다(설계 §3.3). + * + * **해마다 갱신한다.** 표에 없는 해는 공휴일 여부를 모르므로(isHolidayTableYear) 쓰는 쪽이 경고를 남긴다. + * 근거(2026-11-08 확인): 「관공서의 공휴일에 관한 규정」(2026-04-28 국무회의 의결 — 노동절·제헌절을 공휴일로, + * 두 날도 주말·다른 공휴일과 겹치면 대체공휴일), 우주항공청 월력요항(2027년 설날 2월 7일 · 추석 9월 15일). + * 대체공휴일: 설·추석 연휴는 일요일·다른 공휴일과 겹칠 때, 나머지(현충일·신정 제외)는 토·일요일과 겹칠 때. + */ + +/** + * 공휴일 종류 — 휴무 표시와 맞춰 보려고 나눈다. + * 설·추석 연휴의 대체공휴일은 연휴로 본다(쉬는 곳이 연휴 내내 쉬는지 당일만 쉬는지 알 수 없어서). + */ +export type HolidayKind = 'new_year' | 'seollal' | 'chuseok' | 'public'; + +export interface Holiday { + name: string; + kind: HolidayKind; +} + +const HOLIDAYS: Readonly> = { + // 2026 + '2026-01-01': { name: '신정', kind: 'new_year' }, + '2026-02-16': { name: '설날 연휴', kind: 'seollal' }, + '2026-02-17': { name: '설날', kind: 'seollal' }, + '2026-02-18': { name: '설날 연휴', kind: 'seollal' }, + '2026-03-01': { name: '삼일절', kind: 'public' }, + '2026-03-02': { name: '대체공휴일(삼일절)', kind: 'public' }, + '2026-05-01': { name: '노동절', kind: 'public' }, + '2026-05-05': { name: '어린이날', kind: 'public' }, + '2026-05-24': { name: '부처님오신날', kind: 'public' }, + '2026-05-25': { name: '대체공휴일(부처님오신날)', kind: 'public' }, + '2026-06-03': { name: '전국동시지방선거', kind: 'public' }, + '2026-06-06': { name: '현충일', kind: 'public' }, + '2026-07-17': { name: '제헌절', kind: 'public' }, + '2026-08-15': { name: '광복절', kind: 'public' }, + '2026-08-17': { name: '대체공휴일(광복절)', kind: 'public' }, + '2026-09-24': { name: '추석 연휴', kind: 'chuseok' }, + '2026-09-25': { name: '추석', kind: 'chuseok' }, + '2026-09-26': { name: '추석 연휴', kind: 'chuseok' }, + '2026-10-03': { name: '개천절', kind: 'public' }, + '2026-10-05': { name: '대체공휴일(개천절)', kind: 'public' }, + '2026-10-09': { name: '한글날', kind: 'public' }, + '2026-12-25': { name: '성탄절', kind: 'public' }, + // 2027 + '2027-01-01': { name: '신정', kind: 'new_year' }, + '2027-02-06': { name: '설날 연휴', kind: 'seollal' }, + '2027-02-07': { name: '설날', kind: 'seollal' }, + '2027-02-08': { name: '설날 연휴', kind: 'seollal' }, + '2027-02-09': { name: '대체공휴일(설날)', kind: 'seollal' }, + '2027-03-01': { name: '삼일절', kind: 'public' }, + '2027-05-01': { name: '노동절', kind: 'public' }, + '2027-05-03': { name: '대체공휴일(노동절)', kind: 'public' }, + '2027-05-05': { name: '어린이날', kind: 'public' }, + '2027-05-13': { name: '부처님오신날', kind: 'public' }, + '2027-06-06': { name: '현충일', kind: 'public' }, + '2027-07-17': { name: '제헌절', kind: 'public' }, + '2027-07-19': { name: '대체공휴일(제헌절)', kind: 'public' }, + '2027-08-15': { name: '광복절', kind: 'public' }, + '2027-08-16': { name: '대체공휴일(광복절)', kind: 'public' }, + '2027-09-14': { name: '추석 연휴', kind: 'chuseok' }, + '2027-09-15': { name: '추석', kind: 'chuseok' }, + '2027-09-16': { name: '추석 연휴', kind: 'chuseok' }, + '2027-10-03': { name: '개천절', kind: 'public' }, + '2027-10-04': { name: '대체공휴일(개천절)', kind: 'public' }, + '2027-10-09': { name: '한글날', kind: 'public' }, + '2027-10-11': { name: '대체공휴일(한글날)', kind: 'public' }, + '2027-12-25': { name: '성탄절', kind: 'public' }, + '2027-12-27': { name: '대체공휴일(성탄절)', kind: 'public' }, +}; + +/** 표가 담은 해 — 해마다 다음 해를 더한다 */ +export const HOLIDAY_TABLE_YEARS: readonly number[] = [2026, 2027]; + +/** + * 영업시간 파서가 쉬는날 문장에서 뽑는 휴무 표시(ParsedOpeningHours.holidayClosures)와 공휴일 종류의 대응. + * '공휴일' 휴무는 모든 공휴일에 쉰다. + */ +const ANY_HOLIDAY_CLOSURE = '공휴일'; +const CLOSURES_BY_KIND: Readonly> = { + new_year: ['1월 1일'], + seollal: ['설'], + chuseok: ['추석'], + public: [], +}; + +/** 그날의 공휴일('YYYY-MM-DD'). 아니거나 표에 없는 해면 null */ +export function holidayOn(date: string): Holiday | null { + return HOLIDAYS[date] ?? null; +} + +/** 그해 공휴일 목록(날짜 순) */ +export function holidaysIn(year: number): (Holiday & { date: string })[] { + return Object.entries(HOLIDAYS) + .filter(([date]) => date.startsWith(`${year}-`)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, holiday]) => ({ date, ...holiday })); +} + +/** 표가 그날의 해를 담고 있는가 — false 면 공휴일 여부를 모른다 */ +export function isHolidayTableYear(date: string): boolean { + return HOLIDAY_TABLE_YEARS.includes(Number(date.slice(0, 4))); +} + +/** + * 휴무 표시로 보아 그 공휴일에 쉬는가. + * 설·추석은 당일만 쉬는지 연휴 내내 쉬는지 문장마다 달라 연휴 전체를 쉬는 것으로 본다(닫힌 곳으로 보내지 않게). + */ +export function closedOnHoliday( + holidayClosures: readonly string[], + kind: HolidayKind, +): boolean { + return holidayClosures.some( + (closure) => + closure === ANY_HOLIDAY_CLOSURE || + CLOSURES_BY_KIND[kind].includes(closure), + ); +} diff --git a/docs/design/ai-course.md b/docs/design/ai-course.md index 2ee4cb2..4908c7c 100644 --- a/docs/design/ai-course.md +++ b/docs/design/ai-course.md @@ -1,7 +1,7 @@ # AI 코스 생성 설계 (v0.2, 2026-11-05) 사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다. -코드는 §14 순서로 만들고 있다(2026-11-06 기준 2단계 순수 로직까지). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다. +코드는 §14 순서로 만들고 있다(2026-11-08 기준 3단계 날씨·공휴일까지). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다. §13 의 8가지는 2026-11-05 사용자가 추천안대로 정했다. 구현은 §14 순서로 한다. > v0.1 → v0.2: §13 결정을 반영했다. 평가셋 대구판을 만들며 찾은 것도 고쳤다. @@ -127,7 +127,7 @@ | --- | --- | | 영업 | 그날 날짜 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt(hours, 날짜, 분)` 으로 10분 간격 확인(브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간 포함) | | 영업시간 미확인 | 머무는 시간 중 `isOpenAt` 이 null 인 때가 있으면 미확인이다(`status` 가 unknown · varies 이거나, 그 요일 시간이 없거나, 계절을 정할 수 없는 달에 두 계절 시간이 다를 때). **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 계절별 시간표는 2026-11-06 부터 날짜의 달로 고른다(대구 30곳). 달을 적지 않은 하절기·동절기(7곳)는 3·10·11월에 두 계절 시간이 같은 시각만 판단한다 | -| 공휴일 | 그날이 공휴일·명절이고 `holidayClosures` 가 있으면 뺀다. 공휴일 표는 코드 상수로 두고 해마다 갱신한다 | +| 공휴일 | 그날이 공휴일·명절이고 그 공휴일의 휴무 표시(`holidayClosures`)가 있으면 뺀다. '공휴일' 휴무는 모든 공휴일에, 설·추석 휴무는 그 연휴 내내(대체공휴일 포함) 쉬는 것으로 본다(당일만 쉬는지 알 수 없어서). 공휴일 표는 코드 상수(`shared/utils/kr-holidays.ts`, 2026–2027)로 두고 해마다 갱신한다. 2026년부터 노동절·제헌절도 공휴일이고 대체공휴일이 붙는다 | | 반려동물 (조건에 있을 때) | `not_allowed` 와 크기 불일치(`petSizes` 가 있는데 그 크기가 없음)는 뺀다. 실내(식당·카페·문화시설·쇼핑과 전시)는 `allowed` · `partial` 만 남긴다. 야외 분류와 축제·행사는 `unknown` 도 남기고 결과에 "동반 정보 없음" 경고를 붙인다(§13). 2026-11-05 기준 대구에 동반이 확인된 실내 장소는 0곳이라, 큐레이션 전에는 반려 코스가 야외로만 나온다 | | 행사 시간 | `playtime_text` 를 영업시간 파서로 읽어 시간 범위와 겹쳐야 한다. 못 읽으면 남기고 "행사 시간 확인" 경고를 붙인다 | | 다시 만들기 | 이전 결과의 장소는 남은 후보가 15곳 이상이면 빼고, 아니면 목록 뒤로 보낸다 | @@ -138,6 +138,13 @@ - **날씨**: 기상청 단기예보(동네예보 격자). 오늘부터 3일 뒤까지 나오고, 17시 이후 발표분은 4일 뒤까지 나온다. 그 뒤 날짜는 `weather: null` 이다. 입력 화면에 "예보 없음"을 보여 주고 비 조건은 쓰지 않는다. 격자·발표 시각별로 1시간 캐시한다. 2026-10-29 점검 스크립트로 이미 불러 본 API 다(`docs/research/data-check/`). + - 구현(`backend/src/modules/weather/`, 2026-11-08): 위경도를 5km 격자로 바꾸고, 발표 10분 뒤부터 그 발표를 쓴다. 최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다. + - 강수확률은 시간 범위 중 가장 높은 값이고, 기온은 범위 안 최저·최고, 하늘은 가장 잦은 상태다. 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다. + 예보가 시간 범위 끝까지 닿지 않으면 null 이다. + - 입력 화면을 막지 않게 제한 시간 3초·2번 시도이고, 실패하면 던지지 않고 null 이다. 실패는 2분 동안 다시 부르지 않는다. + 하루 호출은 1,000번까지 센다(캐시 덕에 실제로는 하루 수십 번). 공공데이터포털 키(`PUBLIC_DATA_SERVICE_KEY`)를 장소 동기화와 함께 쓴다. + - 실제 호출 확인: `docker exec wandergo-backend npm run weather:check -- [--lat=… --lng=…] [--start=10:00 --end=20:00]`(키가 필요해 사용자가 실행한다). + 2026-11-08 15:38 확인: 대구 도심 격자 89,90, 14시 발표로 11-08~11-11 예보(11일은 3시간 간격), 11-12 는 예보 없음, 기상청 호출 1번 ### 3.4 후보 줄이기 (최대 40곳) @@ -255,7 +262,7 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려: | --- | --- | | `outcome` | `course` · `partial` | | `title`, `summary`, `notices` | AI | -| `weather` | 기상청 — 날짜 · 강수확률 · 기온 · 발표 시각 | +| `weather` | 기상청 — 날짜 · 강수확률(시간 범위 최고) · 강수 형태 · 하늘 · 기온(최저·최고) · 발표 시각(UTC) | | `window`, `mobility`, `totals` | 코드 — 장소 수 · 전체 시간 · 이동 시간 합계 · 반려 동반 확인한 곳 수 | | `stops[]` | `kind`(place · event), `placeId` / `eventId`, 이름, 분류, 사진(라이선스 포함), `arrive` · `leave` · `minutes`, `reason`(AI), `facts`(그날 영업시간과 출처·확인일, 반려 조건과 확인일, 실내외, 행사 기간), `warnings`(hours_unknown · pet_unknown · event_time_unknown · event_last_day — 그날이 행사 마지막 날), `legToNext`(분 · 이동 수단 · 추정 여부) | | `alternates[]` | 바꿀 장소 번호와 stop 과 같은 필드. **같은 시간대로 검증했고, 앞뒤 구간 이동 시간도 다시 계산해 둔다** — 앱이 AI 호출 없이 바꾼다. 다음 장소 도착 시각은 그대로 두고, 그 사이에서 영업 중인 가장 긴 시간(분류 범위 안)을 머문다. 마지막 장소면 원래 머문 시간까지다 | @@ -410,6 +417,7 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려: | 키 | 위치 | 기본값 | 설명 | | --- | --- | --- | --- | | `ANTHROPIC_API_KEY` | `backend/.env.{env}` | — | 운영에서는 필수(없으면 기동 실패). 개발에서 없으면 AI API 가 `AI_002` 를 준다 | +| `PUBLIC_DATA_SERVICE_KEY` | `backend/.env.{env}` | — | 이미 있는 공공데이터포털 키(장소 동기화). 날씨도 쓴다. 없으면 날씨만 '예보 없음'이다 | | `AI_COURSE_MODEL` | 루트 `.env` → compose | 평가 뒤 결정(Haiku 5.5 부터 시험) | 코스 생성 모델 | | `AI_CONDITION_MODEL` | 루트 `.env` → compose | `claude-haiku-5-5` | 조건 해석 모델 | | `AI_DAILY_LIMIT_GUEST` · `AI_DAILY_LIMIT_MEMBER` | 루트 `.env` → compose | 2 · 5 | §5 | @@ -449,7 +457,7 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다. 1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정 2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝) -3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로) +3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로). 2026-11-08 반영(§3.3) 4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다) 5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석 6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신