generated from ttipo/project-base
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
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>
This commit is contained in:
@@ -7,6 +7,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { CourseGenerationModule } from '@/modules/course-generation/course-generation.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { PlaceModule } from '@/modules/place/place.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
@@ -60,6 +61,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
}),
|
||||
HealthModule,
|
||||
PlaceModule,
|
||||
CourseGenerationModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -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,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,211 @@
|
||||
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,
|
||||
) {}
|
||||
|
||||
/** 서버가 한 대라 기동할 때 남아 있는 작업은 모두 지난 프로세스의 것이다 */
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const result = await this.generations.update(
|
||||
{ status: In([...ACTIVE_STATUSES]) },
|
||||
this.failedValues(),
|
||||
);
|
||||
if (result.affected) {
|
||||
this.logger.warn(
|
||||
`지난 실행에서 끝나지 못한 코스 생성 ${result.affected}건을 실패로 바꿨다`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
EventSearchQueryDto,
|
||||
} from './dto/event-search-query.dto';
|
||||
import { FestivalEvent } from './entities/festival-event.entity';
|
||||
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
|
||||
import type { EventSource } from './place.constants';
|
||||
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
|
||||
import { boundingBox, sqlDistanceM } from './place.sql';
|
||||
import { boundingBox, sqlDistanceM, sqlEventWithinMaxSpan } from './place.sql';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
@@ -32,11 +31,6 @@ const PROVIDER_LABEL: Record<EventSource, string> = {
|
||||
dgfca: '대구문화예술진흥원',
|
||||
};
|
||||
|
||||
/** 종류별 기간 상한 SQL — 상수(EVENT_MAX_SPAN_DAYS)로만 만든다 */
|
||||
const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS)
|
||||
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
|
||||
.join(' ')} END`;
|
||||
|
||||
/**
|
||||
* 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'.
|
||||
* 60일 넘는 상시 프로그램과 숨김 행사는 목록에서 뺀다.
|
||||
@@ -84,7 +78,7 @@ export class FestivalService {
|
||||
.where('e.status = :active', { active: 'active' })
|
||||
.andWhere('e.end_date >= :from AND e.start_date <= :to', { from, to })
|
||||
// 상시 프로그램 제외 — 종류별 기간 상한(전시는 길게)
|
||||
.andWhere(`(e.end_date - e.start_date) <= ${MAX_SPAN_SQL}`);
|
||||
.andWhere(sqlEventWithinMaxSpan('e'));
|
||||
if (query.kind?.length) {
|
||||
qb.andWhere('e.kind IN (:...kinds)', { kinds: query.kind });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다.
|
||||
*/
|
||||
import { EVENT_MAX_SPAN_DAYS } from './place.constants';
|
||||
|
||||
/** 위도 1도의 거리(m) */
|
||||
export const METERS_PER_LAT_DEGREE = 111_320;
|
||||
@@ -39,3 +40,14 @@ export function boundingBox(
|
||||
lngMax: lng + dLng,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 종류별 기간 상한 안의 행사인가(상시 프로그램 제외) — 상수(EVENT_MAX_SPAN_DAYS)로만 만드는 SQL 조건.
|
||||
* @param alias festival_event 별칭
|
||||
*/
|
||||
export function sqlEventWithinMaxSpan(alias: string): string {
|
||||
const cases = Object.entries(EVENT_MAX_SPAN_DAYS)
|
||||
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
|
||||
.join(' ');
|
||||
return `(${alias}.end_date - ${alias}.start_date) <= CASE ${alias}.kind ${cases} END`;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ const POSITIVE_INT_KEYS = [
|
||||
'THROTTLE_LIMIT',
|
||||
// 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
|
||||
'TOUR_API_DAILY_LIMIT',
|
||||
// AI 코스 생성 — 하루 횟수(비로그인·로그인), 하루 비용 상한(정수 달러), AI 호출 제한 시간(ms). 없으면 코드 기본값
|
||||
'AI_DAILY_LIMIT_GUEST',
|
||||
'AI_DAILY_LIMIT_MEMBER',
|
||||
'AI_DAILY_BUDGET_USD',
|
||||
'AI_TIMEOUT_MS',
|
||||
] as const;
|
||||
|
||||
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { maskContact } from './mask-contact';
|
||||
|
||||
describe('maskContact', () => {
|
||||
it('이메일을 가린다', () => {
|
||||
expect(maskContact('결과는 me.test+1@example.co.kr 로 보내 줘')).toBe(
|
||||
'결과는 [이메일] 로 보내 줘',
|
||||
);
|
||||
});
|
||||
|
||||
it('여러 모양의 전화번호를 가린다', () => {
|
||||
expect(maskContact('010-1234-5678 로 연락')).toBe('[전화번호] 로 연락');
|
||||
expect(maskContact('01012345678')).toBe('[전화번호]');
|
||||
expect(maskContact('+82 10 1234 5678')).toBe('[전화번호]');
|
||||
expect(maskContact('식당 053-123-4567, 서울 02-123-4567')).toBe(
|
||||
'식당 [전화번호], 서울 [전화번호]',
|
||||
);
|
||||
expect(maskContact('(053) 123-4567')).toBe('[전화번호]');
|
||||
expect(maskContact('1588-1234')).toBe('[전화번호]');
|
||||
});
|
||||
|
||||
it('날짜·시각·금액·연도는 그대로 둔다', () => {
|
||||
const text =
|
||||
'2026-11-09 토요일 10:00~18:00, 1인 15000원 이하, 1950 2020 년대 감성';
|
||||
expect(maskContact(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 문장 속 전화번호·이메일 모양을 가린다 (순수 함수).
|
||||
* AI 코스 요청 문장은 가린 뒤 저장하고 AI 에 보낸다(docs/design/ai-course.md §6.1·§10).
|
||||
*/
|
||||
|
||||
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
||||
/**
|
||||
* 전화번호 — 국제 표기 휴대전화(+82 10 1234 5678), 국내 번호(010-1234-5678 · 01012345678 · 02-123-4567 · (053) 123-4567),
|
||||
* 대표번호(1588-1234, 하이픈이 있을 때만 — "1950 2020" 같은 숫자를 가리지 않게).
|
||||
* 앞뒤가 숫자인 긴 숫자열 속은 보지 않는다.
|
||||
*/
|
||||
const PHONE =
|
||||
/(?<!\d)(?:\+82[\s.-]?1\d[\s.-]?\d{3,4}[\s.-]?\d{4}|\(?0\d{1,2}\)?[\s.-]?\d{3,4}[\s.-]?\d{4}|1[5-9]\d{2}-\d{4})(?!\d)/g;
|
||||
|
||||
export const MASKED_EMAIL = '[이메일]';
|
||||
export const MASKED_PHONE = '[전화번호]';
|
||||
|
||||
export function maskContact(text: string): string {
|
||||
return text.replace(EMAIL, MASKED_EMAIL).replace(PHONE, MASKED_PHONE);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
hoursOn,
|
||||
isOpenAt,
|
||||
parseOpeningHours,
|
||||
type ParsedOpeningHours,
|
||||
@@ -695,3 +696,43 @@ describe('parseOpeningHours', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hoursOn', () => {
|
||||
it('그 날짜의 영업 구간을 주고, 쉬는 요일이면 빈 배열이다', () => {
|
||||
const p = parseOpeningHours('10:00~22:00', '매주 월요일');
|
||||
expect(hoursOn(p, on(TUE))).toEqual([{ start: at(10), end: at(22) }]);
|
||||
expect(hoursOn(p, on(MON))).toEqual([]);
|
||||
});
|
||||
|
||||
it('상시 개방은 0~24시, 미확인·점포별 상이는 null 이다', () => {
|
||||
expect(hoursOn(parseOpeningHours('상시 개방'), on(WED))).toEqual([
|
||||
{ start: 0, end: at(24) },
|
||||
]);
|
||||
expect(hoursOn(parseOpeningHours(null), on(WED))).toBeNull();
|
||||
expect(hoursOn(parseOpeningHours('점포별 상이'), on(WED))).toBeNull();
|
||||
});
|
||||
|
||||
it('계절별 시간표는 날짜의 달로 고르고, 정할 수 없으면 null 이다', () => {
|
||||
const museum = parseOpeningHours(
|
||||
'[1월~4월/9월~12월]09:00~18:00[5월~8월]08:00~19:00',
|
||||
);
|
||||
expect(hoursOn(museum, on(WED, 11))).toEqual([
|
||||
{ start: at(9), end: at(18) },
|
||||
]);
|
||||
expect(hoursOn(museum, on(WED, 7))).toEqual([
|
||||
{ start: at(8), end: at(19) },
|
||||
]);
|
||||
// 달을 적지 않은 하절기·동절기는 11월에 두 시간표가 달라 정할 수 없다
|
||||
const mansion = parseOpeningHours('하절기 10:00~19:00동절기 10:00~18:00');
|
||||
expect(hoursOn(mansion, on(WED, 11))).toBeNull();
|
||||
expect(hoursOn(mansion, on(WED, 7))).toEqual([
|
||||
{ start: at(10), end: at(19) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('문을 열지 않는 기간이면 빈 배열이다', () => {
|
||||
const p = parseOpeningHours('10:00~19:00', '매년 12월~3월');
|
||||
expect(hoursOn(p, on(WED, 1))).toEqual([]);
|
||||
expect(hoursOn(p, on(WED, 5))).toEqual([{ start: at(10), end: at(19) }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -972,3 +972,32 @@ export function isOpenAt(
|
||||
if (open === true) return true;
|
||||
return carried === null || open === null ? null : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 날짜의 영업 구간(준비시간은 빼지 않은 원래 구간) — AI 코스 결과의 '그날 영업시간'과 프롬프트에 쓴다.
|
||||
* 쉬는 날이면 빈 배열, 정할 수 없으면 null(상태가 미확인·점포별 상이, 그 요일 시간이 없음,
|
||||
* 계절을 달로 정할 수 없는데 시간표마다 다름, 문을 열지 않는 기간일 수 있음). 상시 개방은 0~24시 하나다.
|
||||
*/
|
||||
export function hoursOn(
|
||||
parsed: ParsedOpeningHours,
|
||||
date: string,
|
||||
): TimeRange[] | null {
|
||||
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
|
||||
if (parsed.seasonal && !parsed.seasons) return null;
|
||||
const day = calendarDay(date);
|
||||
if (!day) return null;
|
||||
const periods = (parsed.closedPeriods ?? []).filter((p) =>
|
||||
p.months.includes(day.month),
|
||||
);
|
||||
if (periods.some((p) => p.sureMonths.includes(day.month))) return [];
|
||||
const answers = schedulesFor(parsed, day.month).map((s) =>
|
||||
s.closedWeekdays.includes(day.weekday) ? [] : s.week[day.weekday],
|
||||
);
|
||||
if (answers.length === 0) return null;
|
||||
const first = JSON.stringify(answers[0]);
|
||||
if (answers.some((a) => JSON.stringify(a) !== first)) return null;
|
||||
const ranges = answers[0];
|
||||
if (ranges === null) return null;
|
||||
if (periods.length > 0 && ranges.length > 0) return null;
|
||||
return ranges.map((r) => ({ start: r.start, end: r.end }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getStorageToken } from '@nestjs/throttler';
|
||||
import request from 'supertest';
|
||||
import type { Response } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import {
|
||||
AiUnavailableError,
|
||||
COURSE_AI_CLIENT,
|
||||
type CourseAiClient,
|
||||
type CourseDraftInput,
|
||||
} from '@/modules/course-generation/ai/course-ai.client';
|
||||
import { FakeCourseAiClient } from '@/modules/course-generation/ai/fake-course-ai.client';
|
||||
import { hashDeviceId } from '@/modules/course-generation/device-id.decorator';
|
||||
import { Place } from '@/modules/place/entities/place.entity';
|
||||
import { WeatherService } from '@/modules/weather/weather.service';
|
||||
import { kstDateString } from '@/shared/utils/kst';
|
||||
import { parseOpeningHours } from '@/shared/utils/opening-hours';
|
||||
|
||||
/**
|
||||
* AI 코스 생성 API 계약 — 횟수·접수·진행 조회·결과·취소·평가·실패 코드를 실제 HTTP·DB 로 검증한다.
|
||||
* 시험 장소는 대구 영역 사각형(geo.ts) 남서쪽 끝(실제로는 대구 밖이라 장소 데이터가 없는 곳)에 만들고(source_id 'e2e-cg-'),
|
||||
* 끝나면 지운다. AI 는 가짜(조절 가능), 날씨는 null 로 바꿔 키 없이 돈다.
|
||||
*/
|
||||
const C = { lat: 35.56, lng: 128.31 };
|
||||
/** 후보가 없는 곳(5km 떨어짐) */
|
||||
const EMPTY = { lat: 35.6, lng: 128.36 };
|
||||
const DEVICE_HEADER = 'X-Device-Id';
|
||||
const TERMINAL = ['succeeded', 'failed', 'canceled'];
|
||||
|
||||
type Mode = 'normal' | 'block' | 'unavailable';
|
||||
|
||||
/** 가짜 AI 를 감싸 시험마다 막아 두거나(block) 쓸 수 없게(unavailable) 한다 */
|
||||
class ControllableAi implements CourseAiClient {
|
||||
mode: Mode = 'normal';
|
||||
private readonly fake = new FakeCourseAiClient();
|
||||
|
||||
draftCourse(input: CourseDraftInput) {
|
||||
if (this.mode === 'unavailable') {
|
||||
return Promise.reject(new AiUnavailableError('시험: AI 장애'));
|
||||
}
|
||||
if (this.mode === 'block') {
|
||||
return new Promise<never>((_, reject) => {
|
||||
input.signal.addEventListener('abort', () =>
|
||||
reject(new Error('시험: 끊김')),
|
||||
);
|
||||
});
|
||||
}
|
||||
return this.fake.draftCourse(input);
|
||||
}
|
||||
|
||||
parseConditions(input: Parameters<CourseAiClient['parseConditions']>[0]) {
|
||||
return this.fake.parseConditions(input);
|
||||
}
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: { code: string };
|
||||
}
|
||||
interface GenerationBody {
|
||||
id: string;
|
||||
status: string;
|
||||
stage: string | null;
|
||||
progress: { found: number | null; kept: number | null };
|
||||
remaining: number;
|
||||
rating: string | null;
|
||||
failure: { code: string; notices: string[] } | null;
|
||||
result: {
|
||||
outcome: string;
|
||||
window: { date: string; start: string; end: string };
|
||||
totals: { stops: number };
|
||||
stops: {
|
||||
id: string;
|
||||
arrive: string;
|
||||
leave: string;
|
||||
minutes: number;
|
||||
legToNext: { minutes: number; mode: string } | null;
|
||||
}[];
|
||||
alternates: { forIndex: number }[];
|
||||
} | null;
|
||||
}
|
||||
const body = <T>(res: Response) => res.body as Envelope<T>;
|
||||
|
||||
describe('AI 코스 생성 API (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let ds: DataSource;
|
||||
const ai = new ControllableAi();
|
||||
const devices: string[] = [];
|
||||
const placeIds: string[] = [];
|
||||
const date = kstDateString(new Date(), 1);
|
||||
|
||||
const device = () => {
|
||||
const id = randomUUID();
|
||||
devices.push(id);
|
||||
return id;
|
||||
};
|
||||
const conditions = (overrides: Record<string, unknown> = {}) => ({
|
||||
area: { type: 'point', ...C },
|
||||
date,
|
||||
start: '13:00',
|
||||
end: '19:00',
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
...overrides,
|
||||
});
|
||||
const create = (deviceId: string, overrides: Record<string, unknown> = {}) =>
|
||||
request(app.getHttpServer())
|
||||
.post('/api/course-generations')
|
||||
.set(DEVICE_HEADER, deviceId)
|
||||
.send({ request: '', conditions: conditions(overrides) });
|
||||
const get = (deviceId: string, id: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/course-generations/${id}`)
|
||||
.set(DEVICE_HEADER, deviceId);
|
||||
|
||||
/** 끝날 때까지(또는 원하는 상태까지) 조회한다 */
|
||||
async function waitFor(
|
||||
deviceId: string,
|
||||
id: string,
|
||||
until: (g: GenerationBody) => boolean = (g) => TERMINAL.includes(g.status),
|
||||
): Promise<GenerationBody> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const res = await get(deviceId, id).expect(200);
|
||||
const generation = body<GenerationBody>(res).data;
|
||||
if (until(generation)) return generation;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`생성 ${id} 이 끝나지 않았다`);
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (devices.length > 0) {
|
||||
await ds.query(
|
||||
`DELETE FROM ai_course_generation WHERE device_hash = ANY($1)`,
|
||||
[devices.map(hashDeviceId)],
|
||||
);
|
||||
}
|
||||
await ds.query(`DELETE FROM place WHERE source_id LIKE 'e2e-cg-%'`);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(COURSE_AI_CLIENT)
|
||||
.useValue(ai)
|
||||
.overrideProvider(WeatherService)
|
||||
.useValue({ forecastWindow: () => Promise.resolve(null) })
|
||||
// 시험 요청은 모두 같은 주소에서 온다 — 분당 요청 제한(생성 5회)을 세지 않는다
|
||||
.overrideProvider(getStorageToken())
|
||||
.useValue({
|
||||
increment: () =>
|
||||
Promise.resolve({
|
||||
totalHits: 1,
|
||||
timeToExpire: 60,
|
||||
isBlocked: false,
|
||||
timeToBlockExpire: 0,
|
||||
}),
|
||||
})
|
||||
.compile();
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
await app.init();
|
||||
|
||||
ds = app.get(DataSource);
|
||||
await ds.runMigrations();
|
||||
await cleanup();
|
||||
|
||||
const places = ds.getRepository(Place);
|
||||
const rows: [string, Place['category'], string, number][] = [
|
||||
['1', 'attraction', '상시 개방', 0],
|
||||
['2', 'cafe', '10:00~22:00', 0.002],
|
||||
['3', 'culture', '09:00~22:00', 0.004],
|
||||
['4', 'food', '11:00~22:00', 0.006],
|
||||
['5', 'nature', '상시 개방', -0.002],
|
||||
['6', 'cafe', '10:00~22:00', -0.004],
|
||||
];
|
||||
for (const [key, category, hours, dLat] of rows) {
|
||||
const saved = await places.save(
|
||||
places.create({
|
||||
source: 'tour',
|
||||
sourceId: `e2e-cg-${key}`,
|
||||
category,
|
||||
name: `이투이 코스 ${key}`,
|
||||
lat: C.lat + dLat,
|
||||
lng: C.lng,
|
||||
publicHoursText: hours,
|
||||
publicHours: parseOpeningHours(hours, '연중무휴'),
|
||||
}),
|
||||
);
|
||||
placeIds.push(saved.id);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ai.mode = 'normal';
|
||||
});
|
||||
|
||||
it('X-Device-Id 가 없거나 UUID 가 아니면 VAL_001', async () => {
|
||||
const missing = await request(app.getHttpServer())
|
||||
.get('/api/course-generations/quota')
|
||||
.expect(400);
|
||||
expect(body(missing).error?.code).toBe('VAL_001');
|
||||
const wrong = await request(app.getHttpServer())
|
||||
.get('/api/course-generations/quota')
|
||||
.set(DEVICE_HEADER, 'not-a-uuid')
|
||||
.expect(400);
|
||||
expect(body(wrong).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('오늘 남은 횟수 — 비로그인 하루 2번', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/course-generations/quota')
|
||||
.set(DEVICE_HEADER, device())
|
||||
.expect(200);
|
||||
expect(body(res).data).toMatchObject({
|
||||
limit: 2,
|
||||
used: 0,
|
||||
remaining: 2,
|
||||
tier: 'guest',
|
||||
});
|
||||
});
|
||||
|
||||
it('조건 해석 — 비어 있는 항목은 기본값으로 채우고, 대구 밖 기준 위치는 VAL_001', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/course-conditions')
|
||||
.set(DEVICE_HEADER, device())
|
||||
.send({
|
||||
text: '토요일에 걷기 좋은 곳',
|
||||
baseArea: { type: 'point', ...C },
|
||||
})
|
||||
.expect(200);
|
||||
expect(body(res).data).toMatchObject({
|
||||
conditions: {
|
||||
area: { type: 'point', ...C },
|
||||
party: 2,
|
||||
pet: null,
|
||||
mobility: 'walk',
|
||||
indoorIfRain: true,
|
||||
},
|
||||
inferred: [],
|
||||
issues: [],
|
||||
weather: null,
|
||||
});
|
||||
const outside = await request(app.getHttpServer())
|
||||
.post('/api/course-conditions')
|
||||
.set(DEVICE_HEADER, device())
|
||||
.send({
|
||||
text: '',
|
||||
baseArea: { type: 'point', lat: 37.5665, lng: 126.978 },
|
||||
})
|
||||
.expect(400);
|
||||
expect(body(outside).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('생성을 접수하고 진행을 조회하면 결과가 나오고, 그때 횟수를 차감한다', async () => {
|
||||
const me = device();
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/api/course-generations')
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({
|
||||
request: '연락은 010-1234-5678 로, 산책하고 카페',
|
||||
conditions: conditions(),
|
||||
})
|
||||
.expect(201);
|
||||
const { id, status, remaining } = body<{
|
||||
id: string;
|
||||
status: string;
|
||||
remaining: number;
|
||||
}>(created).data;
|
||||
expect(status).toBe('queued');
|
||||
expect(remaining).toBe(2);
|
||||
|
||||
const done = await waitFor(me, id);
|
||||
expect(done.status).toBe('succeeded');
|
||||
expect(done.remaining).toBe(1);
|
||||
expect(done.progress.found).toBe(6);
|
||||
const result = done.result!;
|
||||
expect(result.window).toEqual({ date, start: '13:00', end: '19:00' });
|
||||
expect(result.stops.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.totals.stops).toBe(result.stops.length);
|
||||
for (const [i, stop] of result.stops.entries()) {
|
||||
expect(placeIds).toContain(stop.id);
|
||||
expect(Date.parse(stop.leave) - Date.parse(stop.arrive)).toBe(
|
||||
stop.minutes * 60_000,
|
||||
);
|
||||
const next = result.stops[i + 1];
|
||||
if (next) {
|
||||
expect(Date.parse(next.arrive)).toBe(
|
||||
Date.parse(stop.leave) + stop.legToNext!.minutes * 60_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 문장의 전화번호는 가려서, 현재 위치는 약 100m 단위로 저장한다
|
||||
const [row] = await ds.query<
|
||||
{
|
||||
request_text: string;
|
||||
counted: boolean;
|
||||
conditions: { area: unknown };
|
||||
}[]
|
||||
>(
|
||||
`SELECT request_text, counted, conditions FROM ai_course_generation WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
expect(row.request_text).toBe('연락은 [전화번호] 로, 산책하고 카페');
|
||||
expect(row.counted).toBe(true);
|
||||
expect(row.conditions.area).toEqual({
|
||||
type: 'point',
|
||||
lat: 35.56,
|
||||
lng: 128.31,
|
||||
});
|
||||
|
||||
// 다른 기기는 볼 수 없다
|
||||
const other = await get(device(), id).expect(404);
|
||||
expect(body(other).error?.code).toBe('RES_001');
|
||||
|
||||
// 평가 — 결과가 나온 생성만
|
||||
const rated = await request(app.getHttpServer())
|
||||
.put(`/api/course-generations/${id}/rating`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({ value: 'like' })
|
||||
.expect(200);
|
||||
expect(body<GenerationBody>(rated).data.rating).toBe('like');
|
||||
const wrongValue = await request(app.getHttpServer())
|
||||
.put(`/api/course-generations/${id}/rating`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({ value: 'love' })
|
||||
.expect(400);
|
||||
expect(body(wrongValue).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('만드는 중이면 AI_004, 취소하면 차감하지 않는다', async () => {
|
||||
const me = device();
|
||||
ai.mode = 'block';
|
||||
const created = await create(me).expect(201);
|
||||
const { id } = body<{ id: string }>(created).data;
|
||||
await waitFor(me, id, (g) => g.stage === 'planning');
|
||||
|
||||
const second = await create(me).expect(409);
|
||||
expect(body(second).error?.code).toBe('AI_004');
|
||||
|
||||
const canceled = await request(app.getHttpServer())
|
||||
.post(`/api/course-generations/${id}/cancellation`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.expect(200);
|
||||
expect(body<GenerationBody>(canceled).data).toMatchObject({
|
||||
status: 'canceled',
|
||||
remaining: 2,
|
||||
});
|
||||
// 취소한 생성은 평가할 수 없다
|
||||
const rate = await request(app.getHttpServer())
|
||||
.put(`/api/course-generations/${id}/rating`)
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({ value: 'dislike' })
|
||||
.expect(400);
|
||||
expect(body(rate).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('하루 2번을 다 쓰면 AI_001', async () => {
|
||||
const me = device();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const { id } = body<{ id: string }>(await create(me).expect(201)).data;
|
||||
expect((await waitFor(me, id)).status).toBe('succeeded');
|
||||
}
|
||||
const third = await create(me).expect(429);
|
||||
expect(body(third).error?.code).toBe('AI_001');
|
||||
});
|
||||
|
||||
it('조건에 맞는 후보가 없으면 failed + AI_003 이고 차감하지 않는다', async () => {
|
||||
const me = device();
|
||||
const created = await create(me, {
|
||||
area: { type: 'point', ...EMPTY },
|
||||
}).expect(201);
|
||||
const done = await waitFor(me, body<{ id: string }>(created).data.id);
|
||||
expect(done).toMatchObject({
|
||||
status: 'failed',
|
||||
failure: { code: 'AI_003', notices: [] },
|
||||
result: null,
|
||||
remaining: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('AI 를 쓸 수 없으면 failed + AI_002', async () => {
|
||||
const me = device();
|
||||
ai.mode = 'unavailable';
|
||||
const created = await create(me).expect(201);
|
||||
const done = await waitFor(me, body<{ id: string }>(created).data.id);
|
||||
expect(done).toMatchObject({
|
||||
status: 'failed',
|
||||
failure: { code: 'AI_002' },
|
||||
});
|
||||
});
|
||||
|
||||
it('지난 날짜·짧은 시간·남의 이전 생성은 VAL_001', async () => {
|
||||
const me = device();
|
||||
const past = await create(me, {
|
||||
date: kstDateString(new Date(), -1),
|
||||
}).expect(400);
|
||||
expect(body(past).error?.code).toBe('VAL_001');
|
||||
const short = await create(me, { start: '13:00', end: '14:00' }).expect(
|
||||
400,
|
||||
);
|
||||
expect(body(short).error?.code).toBe('VAL_001');
|
||||
const previous = await request(app.getHttpServer())
|
||||
.post('/api/course-generations')
|
||||
.set(DEVICE_HEADER, me)
|
||||
.send({
|
||||
request: '',
|
||||
conditions: conditions(),
|
||||
previousGenerationId: randomUUID(),
|
||||
})
|
||||
.expect(400);
|
||||
expect(body(previous).error?.code).toBe('VAL_001');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
# AI 코스 생성 설계 (v0.2, 2026-11-05)
|
||||
|
||||
사용자가 쓴 한 문장과 조건으로 하루 코스를 만들어 보여 주는 기능(디자인 화면 8–10: AI 조건 입력 · 만드는 중 · 결과)을 어떻게 만들지 정한다.
|
||||
코드는 §14 순서로 만들고 있다(2026-11-08 기준 3단계 날씨·공휴일까지). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다.
|
||||
코드는 §14 순서로 만들고 있다(2026-11-08 기준 4단계 API 골격까지, AI 는 가짜). 장소·축제 데이터는 [장소 DB 설계](place-db.md)를 그대로 쓰고, 평가는 [`docs/ai-eval/`](../ai-eval/README.md) 를 쓴다.
|
||||
§13 의 8가지는 2026-11-05 사용자가 추천안대로 정했다. 구현은 §14 순서로 한다.
|
||||
|
||||
> v0.1 → v0.2: §13 결정을 반영했다. 평가셋 대구판을 만들며 찾은 것도 고쳤다.
|
||||
@@ -113,6 +113,8 @@
|
||||
서버를 늘리면 Redis 큐(BullMQ)로 옮긴다(nestjs 스킬의 Redis 확장 지점).
|
||||
- 종료할 때 진행 중인 작업은 AI 호출을 끊고 `failed`(차감 없음)로 남긴다(`OnApplicationShutdown`).
|
||||
기동할 때는 2분 넘게 `running` 인 작업을 `failed` 로 바꾼다.
|
||||
- 구현(2026-11-08, `course-generation.runner.ts`): 서버가 한 대라 기동할 때 남은 `queued`·`running` 은 모두 지난 프로세스의 것으로 보고 `failed`(AI_002, 차감 없음)로 바꾼다.
|
||||
1분마다 2분 넘게 `running` 인 작업도 정리한다. 종료할 때는 DB 연결이 닫히기 전(`beforeApplicationShutdown`)에 남은 작업을 `failed` 로 적는다.
|
||||
|
||||
### 3.2 후보 찾기
|
||||
|
||||
@@ -262,10 +264,11 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| --- | --- |
|
||||
| `outcome` | `course` · `partial` |
|
||||
| `title`, `summary`, `notices` | AI |
|
||||
| `planNotices` | 코드 — 장소를 뺐음(`stop_removed`, 이름·이유) · 실내 후보가 모자람(`indoor_ratio_unmet`). 문구는 앱이 언어별로 만든다 |
|
||||
| `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 호출 없이 바꾼다. 다음 장소 도착 시각은 그대로 두고, 그 사이에서 영업 중인 가장 긴 시간(분류 범위 안)을 머문다. 마지막 장소면 원래 머문 시간까지다 |
|
||||
| `stops[]` | `kind`(place · event), `id`(장소·행사 id), 이름, 분류, 사진(라이선스 포함), `arrive` · `leave`(ISO 8601 UTC) · `minutes`, `reason`(AI), `facts`(그날 영업 구간 `hoursOnDate`·원문·출처·확인일, 반려 조건과 출처, 실내외, 행사 기간), `warnings`(hours_unknown · pet_unknown · event_time_unknown · event_last_day — 그날이 행사 마지막 날), `legToNext`(분 · 거리 · 이동 수단 · 추정 여부) |
|
||||
| `alternates[]` | 바꿀 장소의 자리(`forIndex`, stops 의 0부터)와 stop 과 같은 필드, 앞 장소에서 오는 이동(`legFromPrev`). **같은 시간대로 검증했고, 앞뒤 구간 이동 시간도 다시 계산해 둔다** — 앱이 AI 호출 없이 바꾼다. 다음 장소 도착 시각은 그대로 두고, 그 사이에서 영업 중인 가장 긴 시간(분류 범위 안)을 머문다. 마지막 장소면 원래 머문 시간까지다 |
|
||||
| `sources` | 출처 문구(한국관광공사 TourAPI · 공공누리 유형, 기상청 예보 발표 시각, KOPIS 등) |
|
||||
|
||||
- 결과의 사실 값은 생성 시점의 스냅샷이다. 저장한 코스는 그 뒤로 최신 장소 값을 읽는다(코스 설계에서 정한다).
|
||||
@@ -287,6 +290,11 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| `PUT /api/course-generations/{id}/rating` | `{value: like · dislike}` — "이 코스, 마음에 드세요?" | RES_001, VAL_001 |
|
||||
|
||||
- `status` 는 `queued` · `running` · `succeeded` · `failed` · `canceled` 다. 생성이 실패해도 조회 자체는 200 이고, 이유는 `failure.code` 로 준다(문구는 에러 코드 사전).
|
||||
- 구현(2026-11-08, `backend/src/modules/course-generation/`)에서 정한 것:
|
||||
- `X-Device-Id` 가 없거나 UUID 가 아니면 VAL_001. 응답 언어는 `Accept-Language` 로 고른다(en 이면 영어, 그 밖은 한국어).
|
||||
- 생성 요청의 조건이 틀리면 VAL_001 이다 — 지난 날짜 · 30일보다 먼 날짜 · 2시간 미만 · 12시간 초과 · 오늘 이미 끝난 시간 · 대구 밖 현재 위치 · 없는 기준 장소 · 다른 기기의 이전 생성.
|
||||
- 같은 기기의 동시 요청은 기기 단위 잠금(`pg_advisory_xact_lock`)으로 하나씩 검사한다(진행 중 → AI_004, 횟수·시도 상한 → AI_001, 하루 비용 → AI_002).
|
||||
- 조회 응답에 내가 남긴 평가(`rating`)도 준다. 평가는 결과가 나온 생성만 받는다(아니면 VAL_001). 취소는 끝난 생성이면 그대로 돌려준다.
|
||||
- 저장은 코스 API(`POST /api/courses`, 코스 설계)에 `generationId` 와 바꾼 후보를 함께 보낸다. 로그인이 필요하다.
|
||||
- **요청 제한**: 생성은 분당 5회, 조건 해석은 분당 20회다. 진행 조회는 1~2초 간격이라 기본 한도(분당 100회) 대신 분당 300회로 따로 둔다.
|
||||
통신사 NAT 뒤에서는 여러 사용자가 같은 IP 를 쓰기 때문이다.
|
||||
@@ -320,7 +328,8 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| `conditions` | jsonb |
|
||||
| `status`, `stage` | §3.1 |
|
||||
| `found_count`, `kept_count` | 진행 표시 숫자 |
|
||||
| `result` | jsonb(§3.7). 실패해도 AI 가 남긴 `notices` 는 여기에 둔다 |
|
||||
| `result` | jsonb(§3.7) |
|
||||
| `failure_notices` | jsonb — 실패 이유(AI 가 남긴 알림). `result` 대신 따로 둔다(2026-11-08) |
|
||||
| `failure_code` | AI_002 · AI_003 |
|
||||
| `counted` | 횟수를 차감했는지 |
|
||||
| `previous_id` | 다시 만들기라면 이전 생성 |
|
||||
@@ -332,6 +341,9 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
|
||||
- 인덱스: `(device_hash, created_at)`, `(user_id, created_at)`, `(status)`.
|
||||
- 하루 사용량과 비용은 이 테이블에서 센다. 집계 테이블은 따로 두지 않는다.
|
||||
- 구현하며 더한 것(2026-11-08): `started_at`(멈춘 작업 찾기), `failure_notices`.
|
||||
`conditions` 의 현재 위치(point)는 소수점 셋째 자리(약 100m)까지만 남긴다 — 생성이 끝나면 정확한 위치가 필요 없다.
|
||||
- 조건 해석은 이 테이블에 남지 않는다. 조건 해석 비용을 하루 상한에 넣는 기록은 5단계(Anthropic 연동)에서 더한다.
|
||||
|
||||
### 6.2 지원 지역
|
||||
|
||||
@@ -425,6 +437,8 @@ E01 | ○○ 빛 축제 | 축제 | 실외 | 남 300m | 18:00–22:00 | 반려:
|
||||
| `AI_TIMEOUT_MS` | 루트 `.env` → compose | 30000 | AI 호출 제한 시간 |
|
||||
|
||||
- 숫자는 `parsePositiveInt` 로 읽는다(빈 문자열 방어). 새 필수 값은 `env.validation.ts` 와 테스트에 함께 추가한다.
|
||||
- 4단계(2026-11-08)는 `AI_DAILY_LIMIT_*` · `AI_DAILY_BUDGET_USD` · `AI_TIMEOUT_MS` 를 `ConfigService` 로 읽고 없으면 위 기본값을 쓴다(형식은 `env.validation.ts` 가 본다).
|
||||
compose 로 넘기는 설정은 값을 바꿀 일이 생길 때 더한다 — 그 전에는 `backend/.env.{env}` 에 적으면 된다.
|
||||
- SDK(`@anthropic-ai/sdk`)는 컨테이너 안에서 설치하고, 잠금 파일을 npm 10 으로 확인한다(nestjs 스킬의 의존성 주의 항목).
|
||||
|
||||
## 12. MVP 에서 하지 않는 것
|
||||
@@ -458,7 +472,7 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
|
||||
1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정
|
||||
2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3) → 후보 거르기 · 줄이기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요). 2026-11-06 반영(§3.6 끝)
|
||||
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로). 2026-11-08 반영(§3.3)
|
||||
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다)
|
||||
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다). 2026-11-08 반영 — 5단계 전까지 서버는 가짜 AI(`FakeCourseAiClient`)로 코스를 만든다
|
||||
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석
|
||||
6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신
|
||||
7. Flutter 화면 8–10 연결
|
||||
|
||||
@@ -11,6 +11,10 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
'FILE_002': '파일 크기가 허용 범위를 초과했습니다.',
|
||||
'AI_001': '오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.',
|
||||
'AI_002': '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
'AI_003': '조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.',
|
||||
'AI_004': '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -11,6 +11,10 @@ export const ERROR_CODE_MESSAGES = {
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
AI_001: '오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.',
|
||||
AI_002: '지금은 AI 코스를 만들 수 없어요. 잠시 후 다시 시도해 주세요.',
|
||||
AI_003: '조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.',
|
||||
AI_004: '이미 코스를 만들고 있어요. 잠시만 기다려 주세요.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
Reference in New Issue
Block a user