diff --git a/backend/.env.development.example b/backend/.env.development.example index 8702de8..397073f 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -18,6 +18,9 @@ JWT_REFRESH_EXPIRES_IN=14d # 한 키로 활용신청이 승인된 모든 API(관광공사 국문·영문·반려동물, 기상청 단기예보, 상가정보)를 호출한다. # 개발 계정은 관광공사 API 하루 1,000건 제한이 있다. PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key +# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정). +# 운영 계정 승인 후 승인된 한도로 올린다. +TOUR_API_DAILY_LIMIT= # 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다. # 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다. diff --git a/backend/.env.production.example b/backend/.env.production.example index 1f0cc1c..4bede34 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -16,6 +16,9 @@ JWT_REFRESH_EXPIRES_IN=14d # 공공데이터포털 일반 인증키(Decoding) — 운영 전 관광공사 API 는 활용사례를 등록해 운영 계정으로 트래픽을 늘린다. PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key +# 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령이 이 안에서 멈춘다). 비우면 1000(개발 계정). +# 운영 계정 승인 후 승인된 한도로 올린다. +TOUR_API_DAILY_LIMIT= # 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지. KAKAO_REST_API_KEY=change-me-kakao-rest-api-key diff --git a/backend/package.json b/backend/package.json index ccc5784..ccda9b5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,9 @@ "migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create", "migration:run": "npm run typeorm -- migration:run", "migration:revert": "npm run typeorm -- migration:revert", - "migration:show": "npm run typeorm -- migration:show" + "migration:show": "npm run typeorm -- migration:show", + "place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts", + "place:sync:prod": "node dist/commands/place-sync.js" }, "dependencies": { "@nestjs/cache-manager": "^3.0.1", diff --git a/backend/src/commands/place-sync.cli.module.ts b/backend/src/commands/place-sync.cli.module.ts new file mode 100644 index 0000000..c276609 --- /dev/null +++ b/backend/src/commands/place-sync.cli.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { PlaceSyncModule } from '@/modules/place/sync/place-sync.module'; +import { buildDatabaseOptions } from '@/shared/config/database.config'; +import { validateEnv } from '@/shared/config/env.validation'; + +/** + * 장소 동기화 CLI 전용 루트 모듈 — HTTP·Rate Limit·캐시 없이 설정·DB·동기화만 올린다. + * 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값). + */ +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, + validate: validateEnv, + }), + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + ...buildDatabaseOptions(), + autoLoadEntities: true, + // 수천 건 upsert 의 SQL 로그는 진행 상황을 가린다 + logging: false, + }), + }), + PlaceSyncModule, + ], +}) +export class PlaceSyncCliModule {} diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts new file mode 100644 index 0000000..d61bf7d --- /dev/null +++ b/backend/src/commands/place-sync.ts @@ -0,0 +1,78 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { WinstonModule } from 'nest-winston'; + +import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status'; +import { + DEFAULT_TOUR_REGION, + TourKorSync, +} from '@/modules/place/sync/tour-kor.sync'; +import { TourPetSync } from '@/modules/place/sync/tour-pet.sync'; +import { winstonConfig } from '@/shared/logger/winston.config'; +import { PlaceSyncCliModule } from './place-sync.cli.module'; + +/** + * 장소 동기화 명령 (설계: docs/design/place-db.md §4). + * 공공데이터 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다. + * + * 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션] + * 운영: node dist/commands/place-sync.js <작업> [옵션] (cron) + */ +const USAGE = `사용법: place:sync <작업> [옵션] + tour-kor 관광공사 국문 장소 적재·갱신 (목록 → 상세). 한도에 닿으면 멈추고, 다시 실행하면 이어서 받는다 + --region=27 법정동 시·도 코드 (기본 27 대구) + --skip-details 목록만 갱신 + tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출) + status 지역별 적재 현황과 오늘 쓴 호출 수 + --region=27`; + +const JOBS = ['tour-kor', 'tour-pet', 'status'] as const; +type Job = (typeof JOBS)[number]; + +function parseArgs(argv: string[]): { + job: Job | null; + region: string; + skipDetails: boolean; +} { + const [first, ...rest] = argv; + const job = (JOBS as readonly string[]).includes(first ?? '') + ? (first as Job) + : null; + const regionArg = rest.find((a) => a.startsWith('--region=')); + const region = regionArg?.split('=')[1]?.trim() || DEFAULT_TOUR_REGION; + return { job, region, skipDetails: rest.includes('--skip-details') }; +} + +async function main(): Promise { + const { job, region, skipDetails } = parseArgs(process.argv.slice(2)); + if (!job || !/^\d{2}$/.test(region)) { + Logger.error(USAGE, 'PlaceSync'); + process.exitCode = 1; + return; + } + + const app = await NestFactory.createApplicationContext(PlaceSyncCliModule, { + logger: WinstonModule.createLogger(winstonConfig), + }); + try { + if (job === 'tour-kor') { + await app.get(TourKorSync).run({ regionCode: region, skipDetails }); + } else if (job === 'tour-pet') { + await app.get(TourPetSync).run(); + } else { + await app.get(PlaceSyncStatus).report(region); + } + } finally { + await app.close(); + } +} + +main().catch((error: unknown) => { + // 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다) + Logger.error( + error instanceof Error ? error.message : String(error), + 'PlaceSync', + ); + process.exitCode = 1; +}); diff --git a/backend/src/migrations/1793599990257-AddPlaceSourceTypeAndDetailSync.ts b/backend/src/migrations/1793599990257-AddPlaceSourceTypeAndDetailSync.ts new file mode 100644 index 0000000..a29d73a --- /dev/null +++ b/backend/src/migrations/1793599990257-AddPlaceSourceTypeAndDetailSync.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 관광공사 동기화 준비: + * - source_type: 관광타입(contenttypeid) — 소개정보(detailIntro2) 조회에 필요 + * - detail_synced_at: 상세를 받은 시각 — 하루 한도로 나뉜 초기 적재를 이어서 받는다 + * - 분류에 'other'(교육시설·연회장 등) 추가 + * + * ⚠️ CHECK 제약 변경은 migration:generate 가 감지하지 못해(이름이 같으면 같은 제약으로 본다) 직접 적었다. + */ +export class AddPlaceSourceTypeAndDetailSync1793599990257 implements MigrationInterface { + name = 'AddPlaceSourceTypeAndDetailSync1793599990257'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "place" ADD "source_type" text`); + await queryRunner.query( + `ALTER TABLE "place" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`, + ); + await queryRunner.query( + `ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging', 'other'))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // 'other' 로 들어간 장소가 있으면 되돌리기 전에 정리해야 한다(제약 위반으로 실패한다) + await queryRunner.query( + `ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`, + ); + await queryRunner.query( + `ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging'))`, + ); + await queryRunner.query( + `ALTER TABLE "place" DROP COLUMN "detail_synced_at"`, + ); + await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "source_type"`); + } +} diff --git a/backend/src/modules/place/entities/place.entity.ts b/backend/src/modules/place/entities/place.entity.ts index 2da5980..78bc027 100644 --- a/backend/src/modules/place/entities/place.entity.ts +++ b/backend/src/modules/place/entities/place.entity.ts @@ -69,6 +69,10 @@ export class Place { @Column({ type: 'text', nullable: true }) sourceCategory!: string | null; + /** 관광공사 관광타입(contenttypeid: 12 관광지·14 문화시설·39 음식점 …) — 소개정보 조회에 필요하다 */ + @Column({ type: 'text', nullable: true }) + sourceType!: string | null; + @Column({ type: 'text' }) name!: string; @@ -156,6 +160,13 @@ export class Place { @Column({ type: 'timestamptz', nullable: true }) lastSeenAt!: Date | null; + /** + * 상세(개요·영업시간 등)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다. + * 개발 계정 한도(오퍼레이션별 하루 1,000건) 때문에 초기 적재가 며칠에 나뉘므로, 이 값으로 이어서 받는다. + */ + @Column({ type: 'timestamptz', nullable: true }) + detailSyncedAt!: Date | null; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/backend/src/modules/place/place.constants.ts b/backend/src/modules/place/place.constants.ts index 6d0e37f..3124eb1 100644 --- a/backend/src/modules/place/place.constants.ts +++ b/backend/src/modules/place/place.constants.ts @@ -1,13 +1,20 @@ /** * 장소 DB 의 코드값 — 엔티티 CHECK 제약·place_listing 뷰·서비스가 이 목록 하나를 공유한다. * 설계: docs/design/place-db.md + * + * ⚠️ 목록을 바꾸면 CHECK 제약도 바뀌는데, migration:generate 는 이름이 같은 CHECK 제약의 식 변경을 + * 감지하지 못한다. 새 마이그레이션에 DROP CONSTRAINT / ADD CONSTRAINT 를 직접 적는다 + * (예: 1793599990257-AddPlaceSourceTypeAndDetailSync). 뷰 식 변경은 generate 가 감지한다. */ /** 데이터 출처: 관광공사(TourAPI) · 소상공인 상가정보 */ export const PLACE_SOURCES = ['tour', 'store'] as const; export type PlaceSource = (typeof PLACE_SOURCES)[number]; -/** 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6) */ +/** + * 자체 분류 — 검색 탭·AI 코스 슬롯에 쓴다 (설계 §2.6, 대응표는 sync/tour-mapper.ts). + * other: 교육시설·연회장처럼 데이트 장소가 아닌 곳 — 검색 '전체'에만 나오고 AI 후보가 아니다. + */ export const PLACE_CATEGORIES = [ 'nature', 'attraction', @@ -18,6 +25,7 @@ export const PLACE_CATEGORIES = [ 'bar', 'shopping', 'lodging', + 'other', ] as const; export type PlaceCategory = (typeof PLACE_CATEGORIES)[number]; diff --git a/backend/src/modules/place/sync/call-budget.spec.ts b/backend/src/modules/place/sync/call-budget.spec.ts new file mode 100644 index 0000000..c3160a7 --- /dev/null +++ b/backend/src/modules/place/sync/call-budget.spec.ts @@ -0,0 +1,34 @@ +import { BudgetExceededError, CallBudget, sumCalls } from './call-budget'; + +describe('CallBudget', () => { + it('오늘 이미 쓴 수를 빼고 남은 호출 수를 센다', () => { + const budget = new CallBudget(10, { detailIntro2: 7 }); + expect(budget.remaining('detailIntro2')).toBe(3); + expect(budget.remaining('detailCommon2')).toBe(10); + }); + + it('한도를 넘기면 BudgetExceededError 를 던지고 수를 늘리지 않는다', () => { + const budget = new CallBudget(2); + budget.take('detailCommon2'); + budget.take('detailCommon2'); + expect(() => budget.take('detailCommon2')).toThrow(BudgetExceededError); + expect(budget.spent()).toEqual({ detailCommon2: 2 }); + }); + + it('이번 실행에서 쓴 수만 따로 돌려준다(이전 실행분 제외)', () => { + const budget = new CallBudget(100, { areaBasedList2: 5 }); + budget.take('areaBasedList2'); + expect(budget.spent()).toEqual({ areaBasedList2: 1 }); + }); +}); + +describe('sumCalls', () => { + it('여러 실행 기록의 오퍼레이션별 호출 수를 합친다', () => { + expect( + sumCalls([ + { detailIntro2: 400, areaBasedList2: 2 }, + { detailIntro2: 300 }, + ]), + ).toEqual({ detailIntro2: 700, areaBasedList2: 2 }); + }); +}); diff --git a/backend/src/modules/place/sync/call-budget.ts b/backend/src/modules/place/sync/call-budget.ts new file mode 100644 index 0000000..d2cf4b5 --- /dev/null +++ b/backend/src/modules/place/sync/call-budget.ts @@ -0,0 +1,70 @@ +/** + * 관광공사 API 하루 호출 한도 관리. 개발 계정은 **오퍼레이션마다** 하루 1,000건이다. + * 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다. + */ + +/** 한도에 닿았을 때 던진다. 동기화는 이 오류를 받으면 정상 종료한다 */ +export class BudgetExceededError extends Error { + constructor( + readonly operation: string, + readonly reason: 'local' | 'server', + ) { + super( + reason === 'local' + ? `관광공사 ${operation} 오늘 호출 한도에 닿았습니다` + : `관광공사 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`, + ); + this.name = 'BudgetExceededError'; + } +} + +/** 오퍼레이션별 호출 수를 센다. 오늘 이전 실행에서 쓴 수부터 시작한다 */ +export class CallBudget { + private readonly used = new Map(); + private readonly spentThisRun = new Map(); + + constructor( + private readonly limit: number, + usedToday: Record = {}, + ) { + for (const [op, n] of Object.entries(usedToday)) this.used.set(op, n); + } + + remaining(operation: string): number { + return Math.max(0, this.limit - (this.used.get(operation) ?? 0)); + } + + canSpend(operation: string, count = 1): boolean { + return this.remaining(operation) >= count; + } + + /** 호출 1회를 쓴다. 남은 한도가 없으면 BudgetExceededError */ + take(operation: string): void { + if (!this.canSpend(operation)) { + throw new BudgetExceededError(operation, 'local'); + } + this.used.set(operation, (this.used.get(operation) ?? 0) + 1); + this.spentThisRun.set( + operation, + (this.spentThisRun.get(operation) ?? 0) + 1, + ); + } + + /** 이번 실행에서 쓴 호출 수 — place_sync_run.calls 에 남긴다 */ + spent(): Record { + return Object.fromEntries(this.spentThisRun); + } +} + +/** 여러 실행 기록의 오퍼레이션별 호출 수를 합친다 */ +export function sumCalls( + records: readonly Record[], +): Record { + const total: Record = {}; + for (const rec of records) { + for (const [op, n] of Object.entries(rec)) { + if (Number.isFinite(n)) total[op] = (total[op] ?? 0) + n; + } + } + return total; +} diff --git a/backend/src/modules/place/sync/place-sync-status.ts b/backend/src/modules/place/sync/place-sync-status.ts new file mode 100644 index 0000000..975be49 --- /dev/null +++ b/backend/src/modules/place/sync/place-sync-status.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Place } from '../entities/place.entity'; +import { SyncRunRecorder } from './sync-run.recorder'; +import { TourApiFactory } from './tour-api.factory'; + +/** 관광공사 국문 적재에 쓰는 오퍼레이션 */ +const TOUR_KOR_OPERATIONS = [ + 'areaBasedList2', + 'detailCommon2', + 'detailIntro2', + 'detailPetTour2', +]; + +export interface PlaceSyncStatusReport { + byStatus: Record; + byCategory: Record; + detailPending: number; + withPet: number; + usedToday: Record; + dailyLimit: number; +} + +/** 적재 진행 상황 — 지역별 장소 수, 상세 남은 수, 오늘 쓴 호출 수 */ +@Injectable() +export class PlaceSyncStatus { + private readonly logger = new Logger(PlaceSyncStatus.name); + + constructor( + @InjectRepository(Place) private readonly places: Repository, + private readonly recorder: SyncRunRecorder, + private readonly apiFactory: TourApiFactory, + ) {} + + async report(regionCode: string): Promise { + const count = async (column: 'status' | 'category') => { + const rows = await this.places + .createQueryBuilder('p') + .select(`p.${column}`, 'key') + .addSelect('COUNT(*)', 'n') + .where('p.source = :source', { source: 'tour' }) + .andWhere('p.ldong_regn_cd = :region', { region: regionCode }) + .groupBy(`p.${column}`) + .getRawMany<{ key: string; n: string }>(); + return Object.fromEntries(rows.map((r) => [r.key, Number(r.n)])); + }; + const pendingAndPet = await this.places + .createQueryBuilder('p') + .select( + `COUNT(*) FILTER (WHERE p.status = 'active' AND p.detail_synced_at IS NULL)`, + 'pending', + ) + .addSelect( + `COUNT(*) FILTER (WHERE p.public_pet_policy IS NOT NULL)`, + 'pet', + ) + .where('p.source = :source', { source: 'tour' }) + .andWhere('p.ldong_regn_cd = :region', { region: regionCode }) + .getRawOne<{ pending: string; pet: string }>(); + + const report: PlaceSyncStatusReport = { + byStatus: await count('status'), + byCategory: await count('category'), + detailPending: Number(pendingAndPet?.pending ?? 0), + withPet: Number(pendingAndPet?.pet ?? 0), + usedToday: await this.recorder.usedToday(), + dailyLimit: this.apiFactory.dailyLimit(), + }; + + this.logger.log( + `지역 ${regionCode} 관광공사 장소 — 상태 ${JSON.stringify(report.byStatus)}`, + ); + this.logger.log(`분류 ${JSON.stringify(report.byCategory)}`); + this.logger.log( + `상세 남은 장소 ${report.detailPending} · 반려동물 정보 있는 장소 ${report.withPet}`, + ); + const used = TOUR_KOR_OPERATIONS.map( + (op) => `${op} ${report.usedToday[op] ?? 0}/${report.dailyLimit}`, + ).join(' · '); + this.logger.log(`오늘(KST) 이 저장소에서 쓴 호출 — ${used}`); + return report; + } +} diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts new file mode 100644 index 0000000..7c42c62 --- /dev/null +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Place } from '../entities/place.entity'; +import { PlaceSyncRun } from '../entities/place-sync-run.entity'; +import { PlaceSyncStatus } from './place-sync-status'; +import { SyncRunRecorder } from './sync-run.recorder'; +import { TourApiFactory } from './tour-api.factory'; +import { TourKorSync } from './tour-kor.sync'; +import { TourPetSync } from './tour-pet.sync'; + +/** + * 장소 동기화 — 공공데이터를 place 에 적재한다. + * API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다. + */ +@Module({ + imports: [TypeOrmModule.forFeature([Place, PlaceSyncRun])], + providers: [ + TourApiFactory, + SyncRunRecorder, + TourKorSync, + TourPetSync, + PlaceSyncStatus, + ], + exports: [TourKorSync, TourPetSync, PlaceSyncStatus], +}) +export class PlaceSyncModule {} diff --git a/backend/src/modules/place/sync/sync-run.recorder.ts b/backend/src/modules/place/sync/sync-run.recorder.ts new file mode 100644 index 0000000..327acd7 --- /dev/null +++ b/backend/src/modules/place/sync/sync-run.recorder.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MoreThanOrEqual, Repository } from 'typeorm'; + +import { kstStartOfDay } from '@/shared/utils/kst'; +import { PlaceSyncRun } from '../entities/place-sync-run.entity'; +import type { SyncJob } from '../place.constants'; +import { sumCalls } from './call-budget'; + +/** 실행 기록에 남기는 오류 수 상한 */ +const MAX_RECORDED_ERRORS = 20; + +/** place_sync_run 기록 — 실행 시작·종료와 "오늘(KST) 이미 쓴 호출 수" */ +@Injectable() +export class SyncRunRecorder { + constructor( + @InjectRepository(PlaceSyncRun) + private readonly runs: Repository, + ) {} + + start(job: SyncJob, scope: string | null): Promise { + return this.runs.save( + this.runs.create({ job, scope, startedAt: new Date() }), + ); + } + + /** + * 오늘(한국 날짜) 이 저장소에서 기록된 호출 수를 오퍼레이션별로 합친다. + * 같은 키를 다른 도구(점검 스크립트 등)에서 쓴 호출은 여기에 없다 — 그 경우는 서버의 한도 초과 응답으로 멈춘다. + */ + async usedToday(now = new Date()): Promise> { + const today = await this.runs.find({ + where: { startedAt: MoreThanOrEqual(kstStartOfDay(now)) }, + select: { calls: true }, + }); + return sumCalls(today.map((r) => r.calls)); + } + + async finish( + run: PlaceSyncRun, + result: Partial< + Pick< + PlaceSyncRun, + | 'fetched' + | 'inserted' + | 'updated' + | 'hidden' + | 'gone' + | 'calls' + | 'snapshot' + > + > & { errors?: string[] }, + ): Promise { + await this.runs.update(run.id, { + ...result, + errors: (result.errors ?? []).slice(0, MAX_RECORDED_ERRORS), + finishedAt: new Date(), + }); + } +} diff --git a/backend/src/modules/place/sync/tour-api.client.spec.ts b/backend/src/modules/place/sync/tour-api.client.spec.ts new file mode 100644 index 0000000..5628088 --- /dev/null +++ b/backend/src/modules/place/sync/tour-api.client.spec.ts @@ -0,0 +1,134 @@ +import { BudgetExceededError, CallBudget } from './call-budget'; +import { + FetchLike, + TourApiClient, + TourApiError, + parseTourPage, +} from './tour-api.client'; + +const KEY = 'abc+def/ghi=='; + +const okBody = (item: unknown, totalCount = 1) => + JSON.stringify({ + response: { + header: { resultCode: '0000', resultMsg: 'OK' }, + body: { items: item === '' ? '' : { item }, totalCount }, + }, + }); + +function client( + responses: Array<{ status?: number; body?: string; throws?: Error }>, + budget = new CallBudget(100), +) { + const urls: string[] = []; + const fetchImpl: FetchLike = (url) => { + urls.push(url); + const next = responses.shift(); + if (!next) throw new Error('예상보다 많이 호출했다'); + if (next.throws) return Promise.reject(next.throws); + const status = next.status ?? 200; + return Promise.resolve({ + ok: status < 400, + status, + text: () => Promise.resolve(next.body ?? ''), + }); + }; + const api = new TourApiClient({ + serviceKey: KEY, + budget, + fetchImpl, + sleep: () => Promise.resolve(), + }); + return { api, urls, budget }; +} + +describe('parseTourPage', () => { + it('항목이 1건이면 객체로, 0건이면 빈 문자열로 와도 배열로 바꾼다', () => { + expect(parseTourPage(okBody({ contentid: '1' }), 'x').items).toEqual([ + { contentid: '1' }, + ]); + expect(parseTourPage(okBody('', 0), 'x')).toEqual({ + items: [], + totalCount: 0, + }); + }); + + it('결과 코드가 0000 이 아니면 오류로 본다', () => { + const body = JSON.stringify({ + response: { header: { resultCode: '10', resultMsg: 'INVALID_REQUEST' } }, + }); + expect(() => parseTourPage(body, 'detailIntro2')).toThrow( + /detailIntro2: 결과 코드 10 INVALID_REQUEST/, + ); + }); + + it('한도 초과 응답(XML, 코드 22)은 한도 오류로 본다', () => { + const xml = + 'LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR22'; + expect(() => parseTourPage(xml, 'detailCommon2')).toThrow( + BudgetExceededError, + ); + }); + + it('키 오류 같은 XML 응답은 사유를 담은 오류로 본다', () => { + const xml = + 'SERVICE_KEY_IS_NOT_REGISTERED_ERROR30'; + expect(() => parseTourPage(xml, 'areaBasedList2')).toThrow( + /SERVICE_KEY_IS_NOT_REGISTERED_ERROR/, + ); + }); +}); + +describe('TourApiClient', () => { + it('공통 파라미터와 인코딩된 키를 붙여 호출하고 한도를 차감한다', async () => { + const { api, urls, budget } = client([ + { body: okBody({ contentid: '7' }) }, + ]); + const page = await api.get('detailCommon2', { contentId: 7 }); + expect(page.items[0]).toEqual({ contentid: '7' }); + const url = new URL(urls[0]); + expect(url.pathname).toMatch(/\/KorService2\/detailCommon2$/); + expect(url.searchParams.get('serviceKey')).toBe(KEY); + expect(url.searchParams.get('_type')).toBe('json'); + expect(url.searchParams.get('contentId')).toBe('7'); + expect(budget.spent()).toEqual({ detailCommon2: 1 }); + }); + + it('5xx·네트워크 오류는 다시 시도하고, 재시도도 한도에서 차감한다', async () => { + const { api, budget } = client([ + { status: 503 }, + { throws: new Error('socket hang up') }, + { body: okBody({ contentid: '1' }) }, + ]); + await expect(api.get('areaBasedList2', {})).resolves.toMatchObject({ + totalCount: 1, + }); + expect(budget.spent()).toEqual({ areaBasedList2: 3 }); + }); + + it('한도가 없으면 호출하지 않고 BudgetExceededError 를 던진다', async () => { + const { api, urls } = client([], new CallBudget(0)); + await expect(api.get('detailIntro2', {})).rejects.toThrow( + BudgetExceededError, + ); + expect(urls).toHaveLength(0); + }); + + it('오류 메시지에 키가 섞여도 가린다', async () => { + // 최종 메시지는 마지막 시도의 오류로 만든다 — 그 오류에 키가 원문·인코딩 둘 다 섞인 경우 + const { api } = client([ + { throws: new Error('connect failed') }, + { throws: new Error('connect failed') }, + { + throws: new Error( + `connect failed ?serviceKey=${encodeURIComponent(KEY)} (${KEY})`, + ), + }, + ]); + const error = await api.get('detailCommon2', {}).catch((e: Error) => e); + expect(error).toBeInstanceOf(TourApiError); + expect((error as Error).message).toContain('***'); + expect((error as Error).message).not.toContain(KEY); + expect((error as Error).message).not.toContain(encodeURIComponent(KEY)); + }); +}); diff --git a/backend/src/modules/place/sync/tour-api.client.ts b/backend/src/modules/place/sync/tour-api.client.ts new file mode 100644 index 0000000..f2bd0a6 --- /dev/null +++ b/backend/src/modules/place/sync/tour-api.client.ts @@ -0,0 +1,182 @@ +import { BudgetExceededError, CallBudget } from './call-budget'; + +/** 관광공사 국문 관광정보 서비스(KorService2, 매뉴얼 v4.4) */ +export const TOUR_API_BASE_URL = 'https://apis.data.go.kr/B551011/KorService2'; + +const COMMON_PARAMS = { MobileOS: 'ETC', MobileApp: 'WanderGo', _type: 'json' }; +const REQUEST_TIMEOUT_MS = 20_000; +const MAX_ATTEMPTS = 3; // 네트워크 오류·5xx 만 다시 시도한다 +const RETRY_DELAY_MS = 1_000; +const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비) + +/** 공공데이터포털이 한도 초과로 거부할 때의 표시(오류 코드 22) */ +const QUOTA_EXCEEDED_RE = + /LIMITED_NUMBER_OF_SERVICE_REQUESTS|\s*22\s*; + +export interface TourPage { + items: TourItem[]; + totalCount: number; +} + +/** 동기화가 쓰는 관광공사 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */ +export interface TourApi { + get( + operation: string, + params: Record, + ): Promise; +} + +export class TourApiError extends Error { + constructor(message: string) { + super(message); + this.name = 'TourApiError'; + } +} + +interface FetchResponse { + ok: boolean; + status: number; + text(): Promise; +} +export type FetchLike = ( + url: string, + init: { signal: AbortSignal }, +) => Promise; + +export interface TourApiClientOptions { + serviceKey: string; + budget: CallBudget; + baseUrl?: string; + fetchImpl?: FetchLike; + sleep?: (ms: number) => Promise; +} + +const defaultSleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** + * 응답 본문을 목록으로 바꾼다. 관광공사는 항목이 1건이면 배열 대신 객체를, 0건이면 빈 문자열을 준다. + * 키가 틀리면 _type=json 이어도 XML 로 답한다. + */ +export function parseTourPage(body: string, operation: string): TourPage { + let data: unknown; + try { + data = JSON.parse(body); + } catch { + if (QUOTA_EXCEEDED_RE.test(body)) { + throw new BudgetExceededError(operation, 'server'); + } + const reason = /([^<]+) x !== undefined); + const totalCount = Number(resBody?.totalCount ?? items.length); + return { + items, + totalCount: Number.isFinite(totalCount) ? totalCount : items.length, + }; +} + +/** + * 관광공사 API 클라이언트. 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다), + * **키는 어떤 오류 메시지에도 남기지 않는다.** + */ +export class TourApiClient implements TourApi { + private readonly baseUrl: string; + private readonly fetchImpl: FetchLike; + private readonly sleep: (ms: number) => Promise; + private lastCallAt = 0; + + constructor(private readonly options: TourApiClientOptions) { + this.baseUrl = options.baseUrl ?? TOUR_API_BASE_URL; + this.fetchImpl = options.fetchImpl ?? fetch; + this.sleep = options.sleep ?? defaultSleep; + } + + async get( + operation: string, + params: Record, + ): Promise { + const query = new URLSearchParams({ + serviceKey: this.options.serviceKey, + ...COMMON_PARAMS, + ...Object.fromEntries( + Object.entries(params).map(([k, v]) => [k, String(v)]), + ), + }); + const url = `${this.baseUrl}/${operation}?${query.toString()}`; + + let lastError: unknown; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + this.options.budget.take(operation); + await this.pace(); + try { + const res = await this.fetchImpl(url, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const body = await res.text(); + if (res.status >= 500) { + lastError = new TourApiError(`${operation}: HTTP ${res.status}`); + } else if (!res.ok) { + if (QUOTA_EXCEEDED_RE.test(body)) { + throw new BudgetExceededError(operation, 'server'); + } + throw new TourApiError(`${operation}: HTTP ${res.status}`); + } else { + return parseTourPage(body, operation); + } + } catch (error) { + if ( + error instanceof BudgetExceededError || + error instanceof TourApiError + ) { + throw this.mask(error); + } + lastError = error; // 네트워크 오류·시간 초과 → 다시 시도 + } + if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt); + } + const reason = + lastError instanceof Error ? lastError.message : String(lastError); + throw this.mask( + new TourApiError(`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`), + ); + } + + private async pace(): Promise { + const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now(); + if (wait > 0) await this.sleep(wait); + this.lastCallAt = Date.now(); + } + + /** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */ + private mask(error: E): E { + const key = this.options.serviceKey; + for (const variant of [key, encodeURIComponent(key)]) { + if (variant) error.message = error.message.split(variant).join('***'); + } + return error; + } +} diff --git a/backend/src/modules/place/sync/tour-api.factory.ts b/backend/src/modules/place/sync/tour-api.factory.ts new file mode 100644 index 0000000..7d9ba92 --- /dev/null +++ b/backend/src/modules/place/sync/tour-api.factory.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { parsePositiveInt } from '@/shared/utils/env'; +import { CallBudget } from './call-budget'; +import { TourApi, TourApiClient } from './tour-api.client'; + +/** 개발 계정의 오퍼레이션별 하루 한도. 운영 계정 승인 후 TOUR_API_DAILY_LIMIT 로 올린다 */ +const DEFAULT_DAILY_LIMIT = 1_000; +/** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */ +const SAFETY_MARGIN = 20; + +/** 키·한도 설정을 읽어 관광공사 클라이언트를 만든다. 테스트에서는 이 프로바이더를 바꿔 끼운다 */ +@Injectable() +export class TourApiFactory { + constructor(private readonly config: ConfigService) {} + + /** 이번 실행에서 쓸 오퍼레이션별 상한(하루 한도 − 여유분) */ + dailyLimit(): number { + const limit = parsePositiveInt( + this.config.get('TOUR_API_DAILY_LIMIT'), + DEFAULT_DAILY_LIMIT, + ); + return Math.max(0, limit - SAFETY_MARGIN); + } + + create(budget: CallBudget): TourApi { + const key = this.config.get('PUBLIC_DATA_SERVICE_KEY')?.trim(); + if (!key || /change[-_]?me/i.test(key)) { + // 키 값은 어떤 경우에도 출력하지 않는다 + throw new Error( + 'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요', + ); + } + return new TourApiClient({ serviceKey: key, budget }); + } +} diff --git a/backend/src/modules/place/sync/tour-kor.sync.ts b/backend/src/modules/place/sync/tour-kor.sync.ts new file mode 100644 index 0000000..1d8fe6b --- /dev/null +++ b/backend/src/modules/place/sync/tour-kor.sync.ts @@ -0,0 +1,357 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; + +import { Place } from '../entities/place.entity'; +import type { PlaceCategory } from '../place.constants'; +import { BudgetExceededError, CallBudget } from './call-budget'; +import { SyncRunRecorder } from './sync-run.recorder'; +import type { TourApi, TourItem } from './tour-api.client'; +import { TourApiFactory } from './tour-api.factory'; +import { + INTRO_FIELDS, + SkipReason, + TourListPlace, + commonToDetail, + introToDetail, + listItemToPlace, +} from './tour-mapper'; + +/** 법정동 시·도 코드 — 1차 출시 지역 대구 */ +export const DEFAULT_TOUR_REGION = '27'; + +const LIST_PAGE_SIZE = 1_000; // 2026-11-02 대구 1,193건을 2쪽으로 받음 +const MAX_LIST_PAGES = 50; +const UPSERT_CHUNK = 200; +/** 목록이 기존보다 이만큼 적게 오면 원본 장애로 보고 숨김 처리를 하지 않는다 */ +const MIN_COMPLETE_RATIO = 0.8; +/** 상세 조회가 연속으로 이만큼 실패하면 멈춘다(키·서버 문제일 가능성) */ +const MAX_CONSECUTIVE_FAILURES = 10; +/** 상세를 받는 순서 — 한도 때문에 나뉘어도 앱에 먼저 필요한 분류부터 채운다 */ +const DETAIL_PRIORITY: PlaceCategory[] = [ + 'nature', + 'attraction', + 'culture', + 'cafe', + 'food', + 'leisure', + 'bar', + 'shopping', + 'other', + 'lodging', +]; + +/** 목록 upsert 때 덮어쓰는 컬럼(상세·큐레이션 관련 컬럼은 건드리지 않는다) */ +const LIST_PROPS = [ + 'sourceType', + 'category', + 'sourceCategory', + 'name', + 'addressRoad', + 'lat', + 'lng', + 'ldongRegnCd', + 'ldongSignguCd', + 'phone', + 'imageUrl', + 'imageThumbUrl', + 'imageLicense', + 'sourceModifiedAt', + 'status', + 'lastSeenAt', + 'detailSyncedAt', + 'updatedAt', +] as const satisfies readonly (keyof Place)[]; + +export interface TourKorOptions { + regionCode: string; + /** 목록만 갱신하고 상세는 받지 않는다 */ + skipDetails?: boolean; +} + +export interface TourKorResult { + fetched: number; + totalCount: number; + inserted: number; + updated: number; + hidden: number; + skipped: Partial>; + detailed: number; + detailFailed: number; + detailRemaining: number; + stoppedByBudget: boolean; + calls: Record; + errors: string[]; +} + +/** + * 관광공사 국문 장소 동기화(초기 적재 겸 전체 갱신). + * 1) 시·도 지역 목록을 전부 받아 place 에 upsert — 바뀐 장소는 상세를 다시 받도록 표시 + * 2) 상세(개요·영업시간)를 한도 안에서 받는다. 남은 것은 다음 실행이 이어서 받는다 + * 3) 목록에서 사라진 장소는 hidden (지우지 않는다) + */ +@Injectable() +export class TourKorSync { + private readonly logger = new Logger(TourKorSync.name); + + constructor( + @InjectRepository(Place) private readonly places: Repository, + private readonly recorder: SyncRunRecorder, + private readonly apiFactory: TourApiFactory, + ) {} + + async run(options: TourKorOptions): Promise { + const { regionCode } = options; + const budget = new CallBudget( + this.apiFactory.dailyLimit(), + await this.recorder.usedToday(), + ); + const api = this.apiFactory.create(budget); + const run = await this.recorder.start('tour_kor', regionCode); + const result: TourKorResult = { + fetched: 0, + totalCount: 0, + inserted: 0, + updated: 0, + hidden: 0, + skipped: {}, + detailed: 0, + detailFailed: 0, + detailRemaining: 0, + stoppedByBudget: false, + calls: {}, + errors: [], + }; + + try { + await this.syncList(api, regionCode, result); + if (!options.skipDetails && !result.stoppedByBudget) { + await this.syncDetails(api, budget, regionCode, result); + } + result.detailRemaining = await this.places.countBy({ + source: 'tour', + ldongRegnCd: regionCode, + status: 'active', + detailSyncedAt: IsNull(), + }); + } catch (error) { + result.errors.unshift( + error instanceof Error ? error.message : String(error), + ); + throw error; + } finally { + result.calls = budget.spent(); + await this.recorder.finish(run, { + fetched: result.fetched, + inserted: result.inserted, + updated: result.updated, + hidden: result.hidden, + calls: result.calls, + errors: result.errors, + }); + this.report(result); + } + return result; + } + + private async syncList( + api: TourApi, + regionCode: string, + result: TourKorResult, + ): Promise { + const runStart = new Date(); + const existing = await this.places.find({ + where: { source: 'tour' }, + select: { + id: true, + sourceId: true, + sourceModifiedAt: true, + detailSyncedAt: true, + status: true, + ldongRegnCd: true, + }, + }); + const bySourceId = new Map(existing.map((p) => [p.sourceId, p])); + + const rows: Array> = []; + let complete = false; + for (let page = 1; page <= MAX_LIST_PAGES; page += 1) { + let items: TourItem[]; + try { + const res = await api.get('areaBasedList2', { + lDongRegnCd: regionCode, + numOfRows: LIST_PAGE_SIZE, + pageNo: page, + arrange: 'A', // 제목순 — 쪽을 넘기는 동안 순서가 바뀌지 않게 + }); + items = res.items; + result.totalCount = res.totalCount; + } catch (error) { + if (error instanceof BudgetExceededError) { + result.stoppedByBudget = true; + result.errors.push(error.message); + break; + } + throw error; + } + result.fetched += items.length; + for (const item of items) { + const mapped = listItemToPlace(item); + if ('skip' in mapped) { + result.skipped[mapped.skip] = (result.skipped[mapped.skip] ?? 0) + 1; + continue; + } + const prev = bySourceId.get(mapped.sourceId); + const changed = + !prev || + prev.sourceModifiedAt?.getTime() !== + mapped.sourceModifiedAt?.getTime(); + rows.push({ + ...mapped, + ldongRegnCd: mapped.ldongRegnCd ?? regionCode, + status: 'active', + lastSeenAt: runStart, + updatedAt: runStart, + // 원본이 바뀌었거나 새 장소면 상세를 다시 받는다 + detailSyncedAt: changed ? null : (prev?.detailSyncedAt ?? null), + }); + if (prev) result.updated += 1; + else result.inserted += 1; + } + if (items.length === 0 || result.fetched >= result.totalCount) { + complete = true; + break; + } + } + + await this.upsert(rows); + + // 목록을 끝까지 받았을 때만 사라진 장소를 숨긴다(부분 응답으로 멀쩡한 장소를 숨기지 않게) + const activeBefore = existing.filter( + (p) => p.status === 'active' && p.ldongRegnCd === regionCode, + ).length; + if (!complete) return; + if (rows.length < activeBefore * MIN_COMPLETE_RATIO) { + result.errors.push( + `목록이 기존 노출 장소(${activeBefore})보다 크게 적어(${rows.length}) 숨김 처리를 건너뜀`, + ); + return; + } + const hidden = await this.places + .createQueryBuilder() + .update(Place) + .set({ status: 'hidden' }) + .where('source = :source', { source: 'tour' }) + .andWhere('ldong_regn_cd = :region', { region: regionCode }) + .andWhere('status = :active', { active: 'active' }) + .andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', { + runStart, + }) + .execute(); + result.hidden = hidden.affected ?? 0; + } + + private async upsert(rows: Partial[]): Promise { + const overwrite = LIST_PROPS.map( + (prop) => + this.places.metadata.findColumnWithPropertyName(prop)!.databaseName, + ); + for (let i = 0; i < rows.length; i += UPSERT_CHUNK) { + await this.places + .createQueryBuilder() + .insert() + .into(Place) + .values(rows.slice(i, i + UPSERT_CHUNK)) + .orUpdate(overwrite, ['source', 'source_id']) + .execute(); + } + } + + private async syncDetails( + api: TourApi, + budget: CallBudget, + regionCode: string, + result: TourKorResult, + ): Promise { + const pending = await this.places.find({ + where: { + source: 'tour', + ldongRegnCd: regionCode, + status: 'active', + detailSyncedAt: IsNull(), + }, + select: { id: true, sourceId: true, sourceType: true, category: true }, + }); + pending.sort( + (a, b) => + DETAIL_PRIORITY.indexOf(a.category) - + DETAIL_PRIORITY.indexOf(b.category), + ); + + let consecutiveFailures = 0; + for (const place of pending) { + const typeId = place.sourceType ?? ''; + const needIntro = typeId in INTRO_FIELDS; + if ( + !budget.canSpend('detailCommon2') || + (needIntro && !budget.canSpend('detailIntro2')) + ) { + result.stoppedByBudget = true; + break; + } + try { + const common = await api.get('detailCommon2', { + contentId: place.sourceId, + }); + const intro = needIntro + ? await api.get('detailIntro2', { + contentId: place.sourceId, + contentTypeId: typeId, + }) + : null; + await this.places.update(place.id, { + ...commonToDetail(common.items[0] ?? {}), + ...(intro ? introToDetail(typeId, intro.items[0] ?? {}) : {}), + detailSyncedAt: new Date(), + }); + result.detailed += 1; + consecutiveFailures = 0; + } catch (error) { + if (error instanceof BudgetExceededError) { + result.stoppedByBudget = true; + result.errors.push(error.message); + break; + } + result.detailFailed += 1; + consecutiveFailures += 1; + result.errors.push( + `${place.sourceId}: ${error instanceof Error ? error.message : String(error)}`, + ); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + result.errors.push( + `상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`, + ); + break; + } + } + } + } + + private report(r: TourKorResult): void { + const skipped = Object.entries(r.skipped) + .map(([k, v]) => `${k} ${v}`) + .join(', '); + this.logger.log( + `목록 ${r.fetched}/${r.totalCount}건 → 새 장소 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` + + (skipped ? ` · 건너뜀(${skipped})` : ''), + ); + this.logger.log( + `상세 ${r.detailed}건 받음 · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` + + (r.stoppedByBudget + ? ' — 오늘 한도에 닿아 멈춤, 내일 같은 명령을 다시 실행하면 이어서 받는다' + : ''), + ); + this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`); + for (const e of r.errors.slice(0, 5)) this.logger.warn(e); + } +} diff --git a/backend/src/modules/place/sync/tour-mapper.spec.ts b/backend/src/modules/place/sync/tour-mapper.spec.ts new file mode 100644 index 0000000..7f87a20 --- /dev/null +++ b/backend/src/modules/place/sync/tour-mapper.spec.ts @@ -0,0 +1,181 @@ +import { + commonToDetail, + introToDetail, + listItemToPlace, + petToDetail, + toCategory, +} from './tour-mapper'; + +// 2026-11-02 대구 후보표에서 실제로 본 항목을 줄인 것 +const item = (over: Record = {}) => ({ + contentid: '126536', + contenttypeid: '12', + title: '대구 근대골목(근대로의 여행)', + addr1: '대구광역시 중구 달구벌대로 2029', + addr2: '', + mapx: '128.5867', + mapy: '35.8677', + lDongRegnCd: '27', + lDongSignguCd: '110', + lclsSystm1: 'VE', + lclsSystm2: 'VE04', + lclsSystm3: 'VE040100', + cpyrhtDivCd: 'Type1', + firstimage: 'http://tong.visitkorea.or.kr/a.jpg', + firstimage2: 'http://tong.visitkorea.or.kr/a_s.jpg', + tel: '053-661-2194', + modifiedtime: '20261015093000', + ...over, +}); + +describe('toCategory', () => { + it('소분류·중분류·대분류 순서로 자체 분류를 고른다', () => { + expect(toCategory('12', 'VE', 'VE04', 'VE040300')).toBe('nature'); // 둘레길 + expect(toCategory('12', 'VE', 'VE04', 'VE040100')).toBe('attraction'); // 골목길 + expect(toCategory('12', 'VE', 'VE03', 'VE030100')).toBe('nature'); // 시민공원 + expect(toCategory('14', 'VE', 'VE07', 'VE070100')).toBe('culture'); // 박물관 + expect(toCategory('14', 'VE', 'VE09', 'VE090300')).toBe('other'); // 도서관 + expect(toCategory('39', 'FD', 'FD05', 'FD050100')).toBe('cafe'); + expect(toCategory('39', 'FD', 'FD03', 'FD030100')).toBe('cafe'); // 제과 + expect(toCategory('39', 'FD', 'FD04', 'FD040100')).toBe('bar'); + expect(toCategory('39', 'FD', 'FD01', 'FD010100')).toBe('food'); + expect(toCategory('38', 'SH', 'SH04', 'SH040300')).toBe('shopping'); // 사후면세점 + expect(toCategory('32', 'AC', 'AC06', 'AC060200')).toBe('lodging'); + expect(toCategory('12', 'NA', 'NA04', 'NA040700')).toBe('nature'); + }); + + it('축제·여행코스는 장소가 아니다', () => { + expect(toCategory('15', 'EV', 'EV01', 'EV010200')).toBeNull(); + expect(toCategory('25', null, null, null)).toBeNull(); + }); + + it('분류 코드가 비면 관광타입으로 대신한다', () => { + expect(toCategory('39', null, null, null)).toBe('food'); + expect(toCategory('99', null, null, null)).toBeNull(); + }); +}); + +describe('listItemToPlace', () => { + it('목록 항목을 place 기본 컬럼으로 바꾼다(mapx=경도, mapy=위도)', () => { + expect(listItemToPlace(item())).toEqual({ + source: 'tour', + sourceId: '126536', + sourceType: '12', + category: 'attraction', + sourceCategory: 'VE040100', + name: '대구 근대골목(근대로의 여행)', + addressRoad: '대구광역시 중구 달구벌대로 2029', + lat: 35.8677, + lng: 128.5867, + ldongRegnCd: '27', + ldongSignguCd: '110', + phone: '053-661-2194', + imageUrl: 'http://tong.visitkorea.or.kr/a.jpg', + imageThumbUrl: 'http://tong.visitkorea.or.kr/a_s.jpg', + imageLicense: 'Type1', + sourceModifiedAt: new Date('2026-10-15T00:30:00.000Z'), + }); + }); + + it('축제·좌표 없음·한국 밖 좌표는 건너뛴다', () => { + expect( + listItemToPlace(item({ contenttypeid: '15', lclsSystm1: 'EV' })), + ).toEqual({ skip: 'not_place' }); + expect(listItemToPlace(item({ mapx: '', mapy: '' }))).toEqual({ + skip: 'bad_coords', + }); + expect(listItemToPlace(item({ mapx: '35.8', mapy: '128.5' }))).toEqual({ + skip: 'bad_coords', + }); + }); + + it('알 수 없는 저작권 유형은 비우고, 주소 두 줄은 이어 붙인다', () => { + const p = listItemToPlace( + item({ cpyrhtDivCd: 'Type9', addr2: '(남산동)' }), + ); + expect(p).toMatchObject({ + imageLicense: null, + addressRoad: '대구광역시 중구 달구벌대로 2029 (남산동)', + }); + }); +}); + +describe('commonToDetail', () => { + it('개요의 HTML 을 지우고 홈페이지 URL 만 꺼낸다', () => { + expect( + commonToDetail({ + overview: '근대 건축물이 모인 골목
계산성당과 청라언덕', + homepage: + 'www.jung.daegu.kr', + }), + ).toEqual({ + overview: '근대 건축물이 모인 골목\n계산성당과 청라언덕', + homepage: 'https://www.jung.daegu.kr', + }); + }); +}); + +describe('introToDetail', () => { + it('관광타입에 맞는 영업시간·쉬는날 필드를 읽고 해석한다(39 음식점)', () => { + const d = introToDetail('39', { + opentimefood: '11:00~21:00
(준비 시간 15:30~17:00)', + restdatefood: '매주 월요일', + parkingfood: '가능', + firstmenu: '찜갈비', + usetime: '이 값은 관광지 필드라 읽지 않는다', + }); + expect(d.publicHoursText).toBe('11:00~21:00\n(준비 시간 15:30~17:00)'); + expect(d.publicRestText).toBe('매주 월요일'); + expect(d.publicHours?.status).toBe('parsed'); + expect(d.publicHours?.closedWeekdays).toEqual([1]); + expect(d.publicExtra).toEqual({ parking: '가능', firstMenu: '찜갈비' }); + }); + + it('문화시설은 usetimeculture 와 이용요금을 읽는다', () => { + const d = introToDetail('14', { + usetimeculture: '09:00~18:00', + restdateculture: '매주 월요일', + usefee: '무료', + }); + expect(d.publicHoursText).toBe('09:00~18:00'); + expect(d.publicExtra).toEqual({ fee: '무료' }); + }); + + it('값이 하나도 없으면 해석하지 않는다(미확인)', () => { + expect(introToDetail('12', {})).toEqual({ + publicHoursText: null, + publicRestText: null, + publicHours: null, + publicExtra: null, + }); + }); +}); + +describe('petToDetail', () => { + it('전구역·일부구역을 요약하고 원문을 남긴다', () => { + expect( + petToDetail({ + contentid: '1', + acmpyTypeCd: '전구역 동반가능', + acmpyPsblCpam: '전 견종 동반 가능', + acmpyNeedMtr: '목줄 착용', + etcAcmpyInfo: '', + }), + ).toEqual({ + publicPet: { + acmpyTypeCd: '전구역 동반가능', + acmpyPsblCpam: '전 견종 동반 가능', + acmpyNeedMtr: '목줄 착용', + }, + publicPetPolicy: 'allowed', + }); + expect( + petToDetail({ acmpyTypeCd: '일부구역 동반가능' }).publicPetPolicy, + ).toBe('partial'); + }); + + it('표시가 애매하면 요약하지 않는다(불가로 단정하지 않음)', () => { + expect(petToDetail({ acmpyTypeCd: '동반불가' }).publicPetPolicy).toBeNull(); + expect(petToDetail({ acmpyPsblCpam: '소형견' }).publicPetPolicy).toBeNull(); + }); +}); diff --git a/backend/src/modules/place/sync/tour-mapper.ts b/backend/src/modules/place/sync/tour-mapper.ts new file mode 100644 index 0000000..9167b9c --- /dev/null +++ b/backend/src/modules/place/sync/tour-mapper.ts @@ -0,0 +1,301 @@ +import { extractUrl, htmlToText } from '@/shared/utils/html-text'; +import { parseKstTimestamp } from '@/shared/utils/kst'; +import { parseOpeningHours } from '@/shared/utils/opening-hours'; +import type { ParsedOpeningHours } from '@/shared/utils/opening-hours'; +import { IMAGE_LICENSES } from '../place.constants'; +import type { + ImageLicense, + PlaceCategory, + PublicPetPolicy, +} from '../place.constants'; +import type { TourItem } from './tour-api.client'; + +/** + * 관광공사 응답 → place 컬럼 변환 (순수 함수). + * 분류 대응은 「신분류체계정보 관광타입정보 연계 정의서」(docs/data-openapi/) 의 소분류 240개를 보고 정했다. + */ + +/** 장소가 아닌 관광타입 — 15 축제·공연·행사는 festival_event, 25 여행코스는 저장하지 않는다 */ +const SKIP_TYPES = new Set(['15', '25']); + +/** 소분류 단위 예외 */ +const CATEGORY_BY_LCLS3: Record = { + VE040300: 'nature', // 둘레길 + FD030100: 'cafe', // 제과(빵집) — 상가정보 빵/도넛과 같은 칸 +}; + +/** 중분류 단위 */ +const CATEGORY_BY_LCLS2: Record = { + VE01: 'attraction', // 랜드마크(건물·전망대·다리…) + VE02: 'attraction', // 테마공원(테마파크·동물원·수족관…) + VE03: 'nature', // 도시공원 — '산책·자연' 탭 + VE04: 'attraction', // 골목길·문화거리·마을관광지(둘레길은 위 예외) + VE05: 'attraction', // 관광단지·리조트 + VE06: 'culture', // 공연장·영화관 + VE07: 'culture', // 박물관·기념관·전시관·미술관 + VE08: 'other', // 연회장 + VE09: 'other', // 문화원·도서관·학교 등 교육시설 + VE10: 'leisure', // 경기장·스포츠센터 + VE12: 'culture', // 서점·기타문화시설 + FD04: 'bar', // 바·펍·생맥주·전통주점 + FD05: 'cafe', // 카페·찻집 +}; + +/** 대분류 단위 */ +const CATEGORY_BY_LCLS1: Record = { + NA: 'nature', // 자연관광 + HS: 'attraction', // 역사관광 + VE: 'attraction', // 문화관광(위 중분류에 없는 것) + EX: 'leisure', // 체험관광(공예·템플스테이·웰니스…) + LS: 'leisure', // 레저스포츠 + SH: 'shopping', // 쇼핑(백화점·시장·사후면세점…) + FD: 'food', // 음식(카페·주점·제과 제외) + AC: 'lodging', // 숙박 +}; + +/** 분류 코드가 비었을 때 관광타입으로 대신한다 */ +const CATEGORY_BY_TYPE: Record = { + '12': 'attraction', + '14': 'culture', + '28': 'leisure', + '32': 'lodging', + '38': 'shopping', + '39': 'food', +}; + +/** 관광타입별 소개정보(detailIntro2) 필드. 32 숙박은 코스 장소가 아니라 MVP 에서는 부르지 않는다 */ +export const INTRO_FIELDS: Record< + string, + { hours: string; rest: string; extra: Record } +> = { + '12': { + hours: 'usetime', + rest: 'restdate', + extra: { + parking: 'parking', + infocenter: 'infocenter', + season: 'useseason', + }, + }, + '14': { + hours: 'usetimeculture', + rest: 'restdateculture', + extra: { + fee: 'usefee', + parking: 'parkingculture', + parkingFee: 'parkingfee', + infocenter: 'infocenterculture', + spendTime: 'spendtime', + }, + }, + '28': { + hours: 'usetimeleports', + rest: 'restdateleports', + extra: { + fee: 'usefeeleports', + parking: 'parkingleports', + parkingFee: 'parkingfeeleports', + infocenter: 'infocenterleports', + openPeriod: 'openperiod', + reservation: 'reservation', + }, + }, + '38': { + hours: 'opentime', + rest: 'restdateshopping', + extra: { + parking: 'parkingshopping', + infocenter: 'infocentershopping', + fairDay: 'fairday', + saleItem: 'saleitem', + }, + }, + '39': { + hours: 'opentimefood', + rest: 'restdatefood', + extra: { + parking: 'parkingfood', + infocenter: 'infocenterfood', + firstMenu: 'firstmenu', + treatMenu: 'treatmenu', + reservation: 'reservationfood', + packing: 'packing', + }, + }, +}; + +/** 한국 영역 대략값 — 좌표가 0 이거나 뒤바뀐 원본을 거른다 */ +const KOREA_BOUNDS = { latMin: 33, latMax: 39.5, lngMin: 124, lngMax: 132 }; + +function str(item: TourItem, key: string): string | null { + const v = item[key]; + if (typeof v !== 'string' && typeof v !== 'number') return null; + const s = String(v).trim(); + return s === '' ? null : s; +} + +export function toCategory( + typeId: string | null, + lcls1: string | null, + lcls2: string | null, + lcls3: string | null, +): PlaceCategory | null { + if ((typeId && SKIP_TYPES.has(typeId)) || lcls1 === 'EV') return null; + return ( + (lcls3 && CATEGORY_BY_LCLS3[lcls3]) || + (lcls2 && CATEGORY_BY_LCLS2[lcls2]) || + (lcls1 && CATEGORY_BY_LCLS1[lcls1]) || + (typeId && CATEGORY_BY_TYPE[typeId]) || + null + ); +} + +export interface TourListPlace { + source: 'tour'; + sourceId: string; + sourceType: string | null; + category: PlaceCategory; + sourceCategory: string | null; + name: string; + addressRoad: string | null; + lat: number; + lng: number; + ldongRegnCd: string | null; + ldongSignguCd: string | null; + phone: string | null; + imageUrl: string | null; + imageThumbUrl: string | null; + imageLicense: ImageLicense | null; + sourceModifiedAt: Date | null; +} + +export type SkipReason = 'not_place' | 'no_category' | 'bad_coords' | 'no_id'; + +/** 목록(areaBasedList2) 항목 1건 → place 기본 컬럼. 장소가 아니거나 쓸 수 없으면 사유를 돌려준다 */ +export function listItemToPlace( + item: TourItem, +): TourListPlace | { skip: SkipReason } { + const sourceId = str(item, 'contentid'); + const name = htmlToText(item.title); + if (!sourceId || !name) return { skip: 'no_id' }; + const typeId = str(item, 'contenttypeid'); + if (typeId && SKIP_TYPES.has(typeId)) return { skip: 'not_place' }; + const lcls1 = str(item, 'lclsSystm1'); + if (lcls1 === 'EV') return { skip: 'not_place' }; + const lcls2 = str(item, 'lclsSystm2'); + const lcls3 = str(item, 'lclsSystm3'); + const category = toCategory(typeId, lcls1, lcls2, lcls3); + if (!category) return { skip: 'no_category' }; + + const lat = Number(str(item, 'mapy')); + const lng = Number(str(item, 'mapx')); + const b = KOREA_BOUNDS; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lng) || + lat < b.latMin || + lat > b.latMax || + lng < b.lngMin || + lng > b.lngMax + ) { + return { skip: 'bad_coords' }; + } + + const license = str(item, 'cpyrhtDivCd'); + const address = [str(item, 'addr1'), str(item, 'addr2')] + .filter(Boolean) + .join(' '); + return { + source: 'tour', + sourceId, + sourceType: typeId, + category, + sourceCategory: lcls3 ?? lcls2 ?? lcls1, + name, + addressRoad: address === '' ? null : address, + lat, + lng, + ldongRegnCd: str(item, 'lDongRegnCd'), + ldongSignguCd: str(item, 'lDongSignguCd'), + phone: htmlToText(item.tel), + imageUrl: str(item, 'firstimage'), + imageThumbUrl: str(item, 'firstimage2'), + imageLicense: (IMAGE_LICENSES as readonly string[]).includes(license ?? '') + ? (license as ImageLicense) + : null, + sourceModifiedAt: parseKstTimestamp(str(item, 'modifiedtime')), + }; +} + +export interface TourCommonDetail { + overview: string | null; + homepage: string | null; +} + +/** 공통정보(detailCommon2) → 개요·홈페이지 */ +export function commonToDetail(item: TourItem): TourCommonDetail { + return { + overview: htmlToText(item.overview), + homepage: extractUrl(item.homepage), + }; +} + +export interface TourIntroDetail { + publicHoursText: string | null; + publicRestText: string | null; + publicHours: ParsedOpeningHours | null; + publicExtra: Record | null; +} + +/** 소개정보(detailIntro2) → 영업시간·쉬는날 원문, 해석 결과, 부가 정보(요금·주차 등) */ +export function introToDetail(typeId: string, item: TourItem): TourIntroDetail { + const fields = INTRO_FIELDS[typeId]; + if (!fields) { + return { + publicHoursText: null, + publicRestText: null, + publicHours: null, + publicExtra: null, + }; + } + const hours = htmlToText(item[fields.hours]); + const rest = htmlToText(item[fields.rest]); + const extra: Record = {}; + for (const [name, key] of Object.entries(fields.extra)) { + const v = htmlToText(item[key]); + if (v) extra[name] = v; + } + return { + publicHoursText: hours, + publicRestText: rest, + publicHours: hours || rest ? parseOpeningHours(hours, rest) : null, + publicExtra: Object.keys(extra).length > 0 ? extra : null, + }; +} + +export interface TourPetDetail { + publicPet: Record | null; + publicPetPolicy: PublicPetPolicy | null; +} + +/** + * 반려동물 동반 정보(detailPetTour2) → 원문 + 요약. + * 관광공사 정보는 "동반 가능한 곳"만 담는다 — 표시가 애매하면 요약하지 않고(null) 원문만 둔다. + */ +export function petToDetail(item: TourItem): TourPetDetail { + const raw: Record = {}; + for (const [key, value] of Object.entries(item)) { + if (key === 'contentid') continue; + const v = htmlToText(value); + if (v) raw[key] = v; + } + const type = raw.acmpyTypeCd ?? ''; + let policy: PublicPetPolicy | null = null; + if (/불가/.test(type)) policy = null; + else if (/일부/.test(type)) policy = 'partial'; + else if (/전구역|동반\s*가능/.test(type)) policy = 'allowed'; + return { + publicPet: Object.keys(raw).length > 0 ? raw : null, + publicPetPolicy: policy, + }; +} diff --git a/backend/src/modules/place/sync/tour-pet.sync.ts b/backend/src/modules/place/sync/tour-pet.sync.ts new file mode 100644 index 0000000..45a6f18 --- /dev/null +++ b/backend/src/modules/place/sync/tour-pet.sync.ts @@ -0,0 +1,132 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Place } from '../entities/place.entity'; +import { BudgetExceededError, CallBudget } from './call-budget'; +import { SyncRunRecorder } from './sync-run.recorder'; +import type { TourItem } from './tour-api.client'; +import { TourApiFactory } from './tour-api.factory'; +import { TourPetDetail, petToDetail } from './tour-mapper'; + +const PAGE_SIZE = 1_000; +const MAX_PAGES = 30; // 2026-10 전국 약 9,700건 + +export interface TourPetResult { + fetched: number; + totalCount: number; + updated: number; + cleared: number; + complete: boolean; + calls: Record; +} + +/** + * 관광공사 반려동물 동반 정보 반영. detailPetTour2 는 contentId 없이 부르면 전국 전체를 준다(약 10회). + * 받은 정보를 place(관광공사) 에 붙이고, 목록에서 빠진 장소는 정보를 지운다(끝까지 받았을 때만). + */ +@Injectable() +export class TourPetSync { + private readonly logger = new Logger(TourPetSync.name); + + constructor( + @InjectRepository(Place) private readonly places: Repository, + private readonly recorder: SyncRunRecorder, + private readonly apiFactory: TourApiFactory, + ) {} + + async run(): Promise { + const budget = new CallBudget( + this.apiFactory.dailyLimit(), + await this.recorder.usedToday(), + ); + const api = this.apiFactory.create(budget); + const run = await this.recorder.start('tour_pet', null); + const result: TourPetResult = { + fetched: 0, + totalCount: 0, + updated: 0, + cleared: 0, + complete: false, + calls: {}, + }; + const errors: string[] = []; + + try { + const byId = new Map(); + for (let page = 1; page <= MAX_PAGES; page += 1) { + let items: TourItem[]; + try { + const res = await api.get('detailPetTour2', { + numOfRows: PAGE_SIZE, + pageNo: page, + }); + items = res.items; + result.totalCount = res.totalCount; + } catch (error) { + if (error instanceof BudgetExceededError) { + errors.push(error.message); + break; + } + throw error; + } + result.fetched += items.length; + for (const item of items) { + const raw = item.contentid; + const id = + typeof raw === 'string' || typeof raw === 'number' + ? String(raw) + : null; + if (id) byId.set(id, petToDetail(item)); + } + if (items.length === 0 || result.fetched >= result.totalCount) { + result.complete = true; + break; + } + } + + const places = await this.places.find({ + where: { source: 'tour' }, + select: { + id: true, + sourceId: true, + publicPet: true, + publicPetPolicy: true, + }, + }); + for (const place of places) { + const next = byId.get(place.sourceId); + if (!next && !result.complete) continue; // 덜 받았으면 지우지 않는다 + const target: TourPetDetail = next ?? { + publicPet: null, + publicPetPolicy: null, + }; + const same = + JSON.stringify(place.publicPet) === + JSON.stringify(target.publicPet) && + place.publicPetPolicy === target.publicPetPolicy; + if (same) continue; + await this.places.update(place.id, target); + if (next) result.updated += 1; + else result.cleared += 1; + } + } catch (error) { + errors.unshift(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + result.calls = budget.spent(); + await this.recorder.finish(run, { + fetched: result.fetched, + updated: result.updated + result.cleared, + calls: result.calls, + errors, + }); + this.logger.log( + `반려동물 정보 ${result.fetched}/${result.totalCount}건 받음 → 장소 갱신 ${result.updated} · 정보 삭제 ${result.cleared}` + + (result.complete ? '' : ' — 끝까지 받지 못해 삭제는 건너뜀'), + ); + for (const e of errors.slice(0, 5)) this.logger.warn(e); + } + return result; + } +} diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index 65ef407..01d17d1 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -32,6 +32,12 @@ describe('validateEnv', () => { validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }), ).not.toThrow(); }); + + it('관광공사 API 하루 한도가 숫자가 아니면 실패한다', () => { + expect(() => + validateEnv({ NODE_ENV: 'development', TOUR_API_DAILY_LIMIT: '천' }), + ).toThrow(/TOUR_API_DAILY_LIMIT 는 양의 정수/); + }); }); describe('운영 환경', () => { diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 674a545..8d26011 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -24,6 +24,8 @@ const POSITIVE_INT_KEYS = [ 'DB_PORT', 'THROTTLE_TTL_MS', 'THROTTLE_LIMIT', + // 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다 + 'TOUR_API_DAILY_LIMIT', ] as const; /** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */ diff --git a/backend/src/shared/utils/html-text.spec.ts b/backend/src/shared/utils/html-text.spec.ts new file mode 100644 index 0000000..00e06f8 --- /dev/null +++ b/backend/src/shared/utils/html-text.spec.ts @@ -0,0 +1,40 @@ +import { extractUrl, htmlToText } from './html-text'; + +describe('htmlToText', () => { + it('줄바꿈 태그는 줄바꿈으로, 나머지 태그는 지운다', () => { + expect(htmlToText('주차 가능
소형 20대
대형 2대')).toBe( + '주차 가능\n소형 20대\n대형 2대', + ); + expect(htmlToText('무료 입장')).toBe('무료 입장'); + }); + + it('자주 쓰는 HTML 엔티티를 되돌린다', () => { + expect(htmlToText('A & B <안내>')).toBe('A & B <안내>'); + }); + + it('빈 값·공백뿐인 값은 null 이다', () => { + expect(htmlToText('')).toBeNull(); + expect(htmlToText('
')).toBeNull(); + expect(htmlToText(undefined)).toBeNull(); + }); +}); + +describe('extractUrl', () => { + it('a 태그의 href 를 꺼낸다', () => { + expect( + extractUrl( + 'tour.daegu.go.kr', + ), + ).toBe('https://tour.daegu.go.kr'); + }); + + it('태그 없이 주소만 있으면 그대로, www 로 시작하면 https 를 붙인다', () => { + expect(extractUrl('http://example.kr/a')).toBe('http://example.kr/a'); + expect(extractUrl('www.example.kr')).toBe('https://www.example.kr'); + }); + + it('http(s) 가 아닌 링크는 버린다', () => { + expect(extractUrl('x')).toBeNull(); + expect(extractUrl('홈페이지 없음')).toBeNull(); + }); +}); diff --git a/backend/src/shared/utils/html-text.ts b/backend/src/shared/utils/html-text.ts new file mode 100644 index 0000000..06bc163 --- /dev/null +++ b/backend/src/shared/utils/html-text.ts @@ -0,0 +1,49 @@ +/** + * 공공데이터 텍스트 정리 유틸 (순수 함수). + * 관광공사 응답에는 `
`, `` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등). + */ + +const ENTITIES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', +}; + +/** + * HTML 흔적을 지운 일반 텍스트를 돌려준다. + * `
`·`

` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null. + */ +export function htmlToText(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const text = String(value) + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n[ \t]+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]{2,}/g, ' ') + .trim(); + return text === '' ? null : text; +} + +/** + * 홈페이지 값에서 URL 만 꺼낸다. 관광공사는 `
…` 를 통째로 준다. + * http(s) 가 아닌 값은 버린다(javascript: 등). `www.` 로 시작하면 https 를 붙인다. + */ +export function extractUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const href = /href\s*=\s*["']([^"']+)["']/i.exec(value)?.[1]; + const raw = ( + href ?? /(https?:\/\/[^\s"'<>]+|www\.[^\s"'<>]+)/i.exec(value)?.[1] + )?.trim(); + if (!raw) return null; + if (/^https?:\/\//i.test(raw)) return raw; + if (/^www\./i.test(raw)) return `https://${raw}`; + return null; +} diff --git a/backend/src/shared/utils/kst.spec.ts b/backend/src/shared/utils/kst.spec.ts new file mode 100644 index 0000000..b015f52 --- /dev/null +++ b/backend/src/shared/utils/kst.spec.ts @@ -0,0 +1,38 @@ +import { kstStartOfDay, parseKstTimestamp } from './kst'; + +describe('kstStartOfDay', () => { + it('한국 날짜의 0시를 돌려준다', () => { + // 2026-11-02 14:00 KST = 05:00 UTC → 그날 0시 KST = 11-01 15:00 UTC + expect(kstStartOfDay(new Date('2026-11-02T05:00:00Z')).toISOString()).toBe( + '2026-11-01T15:00:00.000Z', + ); + }); + + it('UTC 로는 전날이어도 한국 날짜가 바뀌었으면 새 날로 본다', () => { + // 2026-11-02 00:30 KST = 11-01 15:30 UTC + expect(kstStartOfDay(new Date('2026-11-01T15:30:00Z')).toISOString()).toBe( + '2026-11-01T15:00:00.000Z', + ); + }); +}); + +describe('parseKstTimestamp', () => { + it('14자리 한국 시간을 해석한다', () => { + expect(parseKstTimestamp('20261102143000')?.toISOString()).toBe( + '2026-11-02T05:30:00.000Z', + ); + }); + + it('8자리 날짜는 그날 0시(KST)로 본다', () => { + expect(parseKstTimestamp('20261102')?.toISOString()).toBe( + '2026-11-01T15:00:00.000Z', + ); + }); + + it('형식이 다르거나 없는 날짜면 null', () => { + expect(parseKstTimestamp('2026-11-02')).toBeNull(); + expect(parseKstTimestamp('20260230')).toBeNull(); + expect(parseKstTimestamp('')).toBeNull(); + expect(parseKstTimestamp(null)).toBeNull(); + }); +}); diff --git a/backend/src/shared/utils/kst.ts b/backend/src/shared/utils/kst.ts new file mode 100644 index 0000000..302ec38 --- /dev/null +++ b/backend/src/shared/utils/kst.ts @@ -0,0 +1,36 @@ +/** + * 한국 시간(KST, UTC+9) 유틸 (순수 함수). 한국은 서머타임이 없어 고정 오프셋으로 계산한다. + */ + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +/** 주어진 시각이 속한 한국 날짜의 0시(KST)를 Date 로 돌려준다 */ +export function kstStartOfDay(now: Date): Date { + const kst = new Date(now.getTime() + KST_OFFSET_MS); + const midnightUtc = Date.UTC( + kst.getUTCFullYear(), + kst.getUTCMonth(), + kst.getUTCDate(), + ); + return new Date(midnightUtc - KST_OFFSET_MS); +} + +/** + * 'YYYYMMDD' 또는 'YYYYMMDDhhmmss'(한국 시간) 문자열을 Date 로 바꾼다. + * 관광공사 modifiedtime·createdtime 형식이다. 형식이 다르거나 없는 날짜면 null. + */ +export function parseKstTimestamp(value: unknown): Date | null { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const m = /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?$/.exec( + String(value).trim(), + ); + if (!m) return null; + const [y, mo, d, h, mi, s] = m.slice(1).map((v) => Number(v ?? 0)); + if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) { + return null; + } + const date = new Date(Date.UTC(y, mo - 1, d, h, mi, s) - KST_OFFSET_MS); + // 2월 30일처럼 넘어가는 날짜는 거른다 + const back = new Date(date.getTime() + KST_OFFSET_MS); + return back.getUTCDate() === d ? date : null; +} diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 318c7a4..e39e545 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -1,7 +1,7 @@ -# 장소 DB 설계 (v1.1, 2026-11-02) +# 장소 DB 설계 (v1.2, 2026-11-02) WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다. -1단계(엔티티·마이그레이션)를 구현했다 — `backend/src/modules/place/`, `backend/src/migrations/1793597729768-AddPlaceTables.ts`. +1단계(엔티티·마이그레이션)와 2단계(관광공사 국문·반려동물 동기화 명령)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`. 이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다. ## 0. 전제 (이미 정한 것) @@ -88,6 +88,7 @@ erDiagram | `source_id` | text | contentid / bizesId. **`(source, source_id)` 유니크** — 동기화·큐레이션 가져오기의 연결 키 | | `category` | text | 자체 분류(§2.6). 검색 탭·AI 슬롯에 쓴다 | | `source_category` | text | 원본 코드(lclsSystm3 / indsSclsCd) — 분류 규칙을 바꿔도 다시 계산할 수 있게 | +| `source_type` | text | 관광공사 관광타입(contenttypeid). 소개정보(detailIntro2) 조회에 필요하다 | | `name`, `branch_name` | text | 한국어 이름, 지점명(상가정보 brchNm) | | `address_road`, `address_jibun` | text | | | `lat`, `lng` | double precision | WGS84. §5 위치 검색 | @@ -105,6 +106,7 @@ erDiagram | `source_modified_at` | timestamptz | 관광공사 modifiedtime | | `source_snapshot` | text | 상가정보 기준년월(stdrYm) | | `last_seen_at` | timestamptz | 마지막 동기화에서 확인된 시각 | +| `detail_synced_at` | timestamptz | 상세(개요·영업시간)를 받은 시각. null 이면 아직 못 받았거나 원본이 바뀌어 다시 받아야 한다 — 하루 한도로 나뉜 적재를 이어서 받는 기준 | | `created_at`, `updated_at` | timestamptz | | - **지우지 않는다.** 사라진 장소는 `status=gone` 으로 숨긴다. 큐레이션과 코스가 그 장소를 가리키고 있을 수 있다. @@ -172,19 +174,25 @@ erDiagram 검색 탭(관광지 · 축제 · 맛집·카페 · 숙소 · 산책·자연)과 AI 코스 슬롯을 이 값으로 고른다. -| category | 관광공사(lclsSystm1 기준) | 상가정보 | 검색 탭 | -| --- | --- | --- | --- | -| `nature` | NA | — | 산책·자연 | -| `attraction` | HS, VE(문화시설 제외) | — | 관광지 | -| `culture` | VE07(문화시설) | — | 관광지 | -| `leisure` | LS | — | 관광지 | -| `food` | FD(카페 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 | -| `cafe` | FD05 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 | -| `bar` | — | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 | -| `shopping` | SH | — | 관광지 | -| `lodging` | AC | — | 숙소 | +관광공사 대응은 「신분류체계정보 관광타입정보 연계 정의서」(`docs/data-openapi/`, 소분류 240개)를 보고 확정했다. +**소분류 → 중분류 → 대분류 → 관광타입** 순서로 찾는다. 코드는 `backend/src/modules/place/sync/tour-mapper.ts` 가 기준이다. -- 위 대응은 2026-11-02 표본에서 본 코드로 만든 것이다. `lclsSystmCode2` 로 전체 코드표를 받아 확정한다. +| category | 관광공사 | 상가정보 | 검색 탭 | AI 2순위 | +| --- | --- | --- | --- | --- | +| `nature` | NA 자연관광 전체 · VE03 도시공원 · VE040300 둘레길 | — | 산책·자연 | O | +| `attraction` | HS 역사관광 · VE01 랜드마크 · VE02 테마공원 · VE04 골목길·문화거리·마을 · VE05 관광단지 | — | 관광지 | O | +| `culture` | VE06 공연장·영화관 · VE07 박물관·전시관·미술관 · VE12 서점·기타문화시설 | — | 관광지 | O | +| `leisure` | LS 레저스포츠 · EX 체험관광(공예·템플스테이·웰니스) · VE10 경기장·스포츠센터 | — | 관광지 | O | +| `food` | FD 음식(아래 제외) | 그 외 음식 업종(지금은 적재하지 않음) | 맛집·카페 | O | +| `cafe` | FD05 카페·찻집 · FD030100 제과 | I21201 카페 · I21001 빵/도넛 · I21008 아이스크림/빙수 · I21005 샌드위치/샐러드 | 맛집·카페 | O(관광공사만) | +| `bar` | FD04 바·펍·주점 | I21104 요리 주점 · I21103 생맥주 | 맛집·카페 | — | +| `shopping` | SH 쇼핑(백화점·쇼핑몰·시장·사후면세점…) | — | 관광지 | — | +| `lodging` | AC 숙박 | — | 숙소 | — | +| `other` | VE08 연회장 · VE09 문화원·도서관·학교 | — | (전체에만) | — | + +- 저장하지 않는 것: EV 축제·공연·행사(→ `festival_event`), 관광타입 25 여행코스, 좌표가 없거나 한국 밖인 항목. +- 2026-11-02 대구 도심 쇼핑 135곳 대부분이 `SH040300` **사후면세점**(더현대 입점 브랜드 매장)이었다. 쇼핑은 큐레이션으로만 AI 후보가 된다. +- 분류 코드가 비면 관광타입(12 관광지·14 문화시설·28 레포츠·32 숙박·38 쇼핑·39 음식점)으로 대신한다. ## 3. "앱이 보는 값" — `place_listing` 뷰 @@ -230,6 +238,27 @@ erDiagram - 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트). - 동기화는 `place` 만 쓴다. `place_curation` 은 읽지도 쓰지도 않는다. +### 4.1 실행 방법 (구현됨: 관광공사 국문·반려동물) + +```bash +# 개발 — 키가 있는 백엔드 컨테이너 안에서 (dc = docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development) +dc exec backend npm run migration:run # 스키마 먼저 +dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 → 상세. 한도에 닿으면 멈춘다 +dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회) +dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수 +# 다음 날 같은 tour-kor 명령을 다시 실행하면 남은 상세를 이어서 받는다 + +# 운영 — cron +node dist/commands/place-sync.js tour-kor +``` + +- 한도: `TOUR_API_DAILY_LIMIT`(비우면 1,000) − 여유분 20. 오늘(KST) `place_sync_run` 에 기록된 호출부터 센다. + 같은 키를 점검 스크립트 등 다른 곳에서 쓴 호출은 기록에 없으므로, 서버가 한도 초과(코드 22)로 거부하면 그때 멈춘다. +- 상세는 nature → attraction → culture → cafe → food … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터). +- 목록을 끝까지 받았을 때만 사라진 장소를 숨기고, 목록이 기존 노출 장소의 80% 미만이면 원본 장애로 보고 숨기지 않는다. +- 원본 수정일(modifiedtime)이 바뀐 장소는 `detail_synced_at` 을 비워 상세를 다시 받는다. +- 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영. + ## 5. 위치 검색 - `lat`·`lng` double precision + 인덱스. 조회는 "경계 사각형으로 1차 필터 → 하버사인 거리로 정렬". @@ -265,7 +294,6 @@ erDiagram | 항목 | 언제 | | --- | --- | -| `lclsSystm` 전체 코드표로 §2.6 분류 확정 | 엔티티 작성 전 (`lclsSystmCode2` 1회 호출) | | `areaBasedSyncList2` 의 modifiedtime 이 "그날"인지 "그 이후"인지 | 변경분 동기화 첫 실행 | | 영문 ↔ 국문 매칭률 | 영문 첫 적재 | | Type3(변경금지) 이미지를 카드에서 잘라 보여 주는 것이 '변경'인지 | 화면 구현 전 (공공누리 기준) | @@ -275,7 +303,7 @@ erDiagram ## 10. 구현 순서 (제안) 1. ✅ 엔티티 + 마이그레이션: `place`, `place_curation`, `place_i18n`, `festival_event`, `place_sync_run`, `place_listing` 뷰 (2026-11-02) -2. 관광공사 국문 초기 적재 명령(대구) — 개발 계정으로 2일 +2. ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일) 3. 상가정보 적재 명령(대상 행정동) 4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영) 5. 조회 API: `GET /api/places`(검색·필터·커서 페이지네이션), `GET /api/places/{id}`, `GET /api/events`