feat(backend): 장소·축제·공연·전시 데이터 기반(장소 DB·동기화·조회 API) #1

Merged
ttipo merged 30 commits from feat/place-db into main 2026-11-04 13:25:51 +09:00
27 changed files with 1993 additions and 18 deletions
Showing only changes of commit 9775d6de45 - Show all commits
+3
View File
@@ -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 만 저장할 수 있다.
+3
View File
@@ -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
+3 -1
View File
@@ -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",
@@ -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 {}
+78
View File
@@ -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<void> {
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;
});
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 관광공사 동기화 준비:
* - source_type: 관광타입(contenttypeid) — 소개정보(detailIntro2) 조회에 필요
* - detail_synced_at: 상세를 받은 시각 — 하루 한도로 나뉜 초기 적재를 이어서 받는다
* - 분류에 'other'(교육시설·연회장 등) 추가
*
* ⚠️ CHECK 제약 변경은 migration:generate 가 감지하지 못해(이름이 같으면 같은 제약으로 본다) 직접 적었다.
*/
export class AddPlaceSourceTypeAndDetailSync1793599990257 implements MigrationInterface {
name = 'AddPlaceSourceTypeAndDetailSync1793599990257';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "place" ADD "source_type" text`);
await queryRunner.query(
`ALTER TABLE "place" ADD "detail_synced_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging', 'other'))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 'other' 로 들어간 장소가 있으면 되돌리기 전에 정리해야 한다(제약 위반으로 실패한다)
await queryRunner.query(
`ALTER TABLE "place" DROP CONSTRAINT "ck_place_category"`,
);
await queryRunner.query(
`ALTER TABLE "place" ADD CONSTRAINT "ck_place_category" CHECK ("category" IN ('nature', 'attraction', 'culture', 'leisure', 'food', 'cafe', 'bar', 'shopping', 'lodging'))`,
);
await queryRunner.query(
`ALTER TABLE "place" DROP COLUMN "detail_synced_at"`,
);
await queryRunner.query(`ALTER TABLE "place" DROP COLUMN "source_type"`);
}
}
@@ -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;
+9 -1
View File
@@ -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];
@@ -0,0 +1,34 @@
import { BudgetExceededError, CallBudget, sumCalls } from './call-budget';
describe('CallBudget', () => {
it('오늘 이미 쓴 수를 빼고 남은 호출 수를 센다', () => {
const budget = new CallBudget(10, { detailIntro2: 7 });
expect(budget.remaining('detailIntro2')).toBe(3);
expect(budget.remaining('detailCommon2')).toBe(10);
});
it('한도를 넘기면 BudgetExceededError 를 던지고 수를 늘리지 않는다', () => {
const budget = new CallBudget(2);
budget.take('detailCommon2');
budget.take('detailCommon2');
expect(() => budget.take('detailCommon2')).toThrow(BudgetExceededError);
expect(budget.spent()).toEqual({ detailCommon2: 2 });
});
it('이번 실행에서 쓴 수만 따로 돌려준다(이전 실행분 제외)', () => {
const budget = new CallBudget(100, { areaBasedList2: 5 });
budget.take('areaBasedList2');
expect(budget.spent()).toEqual({ areaBasedList2: 1 });
});
});
describe('sumCalls', () => {
it('여러 실행 기록의 오퍼레이션별 호출 수를 합친다', () => {
expect(
sumCalls([
{ detailIntro2: 400, areaBasedList2: 2 },
{ detailIntro2: 300 },
]),
).toEqual({ detailIntro2: 700, areaBasedList2: 2 });
});
});
@@ -0,0 +1,70 @@
/**
* 관광공사 API 하루 호출 한도 관리. 개발 계정은 **오퍼레이션마다** 하루 1,000건이다.
* 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다.
*/
/** 한도에 닿았을 때 던진다. 동기화는 이 오류를 받으면 정상 종료한다 */
export class BudgetExceededError extends Error {
constructor(
readonly operation: string,
readonly reason: 'local' | 'server',
) {
super(
reason === 'local'
? `관광공사 ${operation} 오늘 호출 한도에 닿았습니다`
: `관광공사 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
);
this.name = 'BudgetExceededError';
}
}
/** 오퍼레이션별 호출 수를 센다. 오늘 이전 실행에서 쓴 수부터 시작한다 */
export class CallBudget {
private readonly used = new Map<string, number>();
private readonly spentThisRun = new Map<string, number>();
constructor(
private readonly limit: number,
usedToday: Record<string, number> = {},
) {
for (const [op, n] of Object.entries(usedToday)) this.used.set(op, n);
}
remaining(operation: string): number {
return Math.max(0, this.limit - (this.used.get(operation) ?? 0));
}
canSpend(operation: string, count = 1): boolean {
return this.remaining(operation) >= count;
}
/** 호출 1회를 쓴다. 남은 한도가 없으면 BudgetExceededError */
take(operation: string): void {
if (!this.canSpend(operation)) {
throw new BudgetExceededError(operation, 'local');
}
this.used.set(operation, (this.used.get(operation) ?? 0) + 1);
this.spentThisRun.set(
operation,
(this.spentThisRun.get(operation) ?? 0) + 1,
);
}
/** 이번 실행에서 쓴 호출 수 — place_sync_run.calls 에 남긴다 */
spent(): Record<string, number> {
return Object.fromEntries(this.spentThisRun);
}
}
/** 여러 실행 기록의 오퍼레이션별 호출 수를 합친다 */
export function sumCalls(
records: readonly Record<string, number>[],
): Record<string, number> {
const total: Record<string, number> = {};
for (const rec of records) {
for (const [op, n] of Object.entries(rec)) {
if (Number.isFinite(n)) total[op] = (total[op] ?? 0) + n;
}
}
return total;
}
@@ -0,0 +1,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<string, number>;
byCategory: Record<string, number>;
detailPending: number;
withPet: number;
usedToday: Record<string, number>;
dailyLimit: number;
}
/** 적재 진행 상황 — 지역별 장소 수, 상세 남은 수, 오늘 쓴 호출 수 */
@Injectable()
export class PlaceSyncStatus {
private readonly logger = new Logger(PlaceSyncStatus.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: TourApiFactory,
) {}
async report(regionCode: string): Promise<PlaceSyncStatusReport> {
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;
}
}
@@ -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 {}
@@ -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<PlaceSyncRun>,
) {}
start(job: SyncJob, scope: string | null): Promise<PlaceSyncRun> {
return this.runs.save(
this.runs.create({ job, scope, startedAt: new Date() }),
);
}
/**
* 오늘(한국 날짜) 이 저장소에서 기록된 호출 수를 오퍼레이션별로 합친다.
* 같은 키를 다른 도구(점검 스크립트 등)에서 쓴 호출은 여기에 없다 — 그 경우는 서버의 한도 초과 응답으로 멈춘다.
*/
async usedToday(now = new Date()): Promise<Record<string, number>> {
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<void> {
await this.runs.update(run.id, {
...result,
errors: (result.errors ?? []).slice(0, MAX_RECORDED_ERRORS),
finishedAt: new Date(),
});
}
}
@@ -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 =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR</returnAuthMsg><returnReasonCode>22</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
expect(() => parseTourPage(xml, 'detailCommon2')).toThrow(
BudgetExceededError,
);
});
it('키 오류 같은 XML 응답은 사유를 담은 오류로 본다', () => {
const xml =
'<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>';
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));
});
});
@@ -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|<returnReasonCode>\s*22\s*</i;
export type TourItem = Record<string, unknown>;
export interface TourPage {
items: TourItem[];
totalCount: number;
}
/** 동기화가 쓰는 관광공사 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface TourApi {
get(
operation: string,
params: Record<string, string | number>,
): Promise<TourPage>;
}
export class TourApiError extends Error {
constructor(message: string) {
super(message);
this.name = 'TourApiError';
}
}
interface FetchResponse {
ok: boolean;
status: number;
text(): Promise<string>;
}
export type FetchLike = (
url: string,
init: { signal: AbortSignal },
) => Promise<FetchResponse>;
export interface TourApiClientOptions {
serviceKey: string;
budget: CallBudget;
baseUrl?: string;
fetchImpl?: FetchLike;
sleep?: (ms: number) => Promise<void>;
}
const defaultSleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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 = /<returnAuthMsg>([^<]+)</i.exec(body)?.[1];
throw new TourApiError(
`${operation}: JSON 이 아닌 응답${reason ? ` (${reason})` : ''}`,
);
}
const response = asRecord(asRecord(data)?.response);
const header = asRecord(response?.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
if (code !== '' && code !== '0000') {
if (code === '22') throw new BudgetExceededError(operation, 'server');
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
throw new TourApiError(`${operation}: 결과 코드 ${code} ${msg}`.trim());
}
const resBody = asRecord(response?.body);
const item = asRecord(resBody?.items)?.item;
const items = (Array.isArray(item) ? item : item ? [item] : [])
.map(asRecord)
.filter((x): x is TourItem => 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<void>;
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<string, string | number>,
): Promise<TourPage> {
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<void> {
const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now();
if (wait > 0) await this.sleep(wait);
this.lastCallAt = Date.now();
}
/** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */
private mask<E extends Error>(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;
}
}
@@ -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<string>('TOUR_API_DAILY_LIMIT'),
DEFAULT_DAILY_LIMIT,
);
return Math.max(0, limit - SAFETY_MARGIN);
}
create(budget: CallBudget): TourApi {
const key = this.config.get<string>('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 });
}
}
@@ -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<Record<SkipReason, number>>;
detailed: number;
detailFailed: number;
detailRemaining: number;
stoppedByBudget: boolean;
calls: Record<string, number>;
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<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: TourApiFactory,
) {}
async run(options: TourKorOptions): Promise<TourKorResult> {
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<void> {
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<TourListPlace & Partial<Place>> = [];
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<Place>[]): Promise<void> {
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<void> {
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);
}
}
@@ -0,0 +1,181 @@
import {
commonToDetail,
introToDetail,
listItemToPlace,
petToDetail,
toCategory,
} from './tour-mapper';
// 2026-11-02 대구 후보표에서 실제로 본 항목을 줄인 것
const item = (over: Record<string, unknown> = {}) => ({
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: '근대 건축물이 모인 골목<br>계산성당과 청라언덕',
homepage:
'<a href="https://www.jung.daegu.kr" target="_blank">www.jung.daegu.kr</a>',
}),
).toEqual({
overview: '근대 건축물이 모인 골목\n계산성당과 청라언덕',
homepage: 'https://www.jung.daegu.kr',
});
});
});
describe('introToDetail', () => {
it('관광타입에 맞는 영업시간·쉬는날 필드를 읽고 해석한다(39 음식점)', () => {
const d = introToDetail('39', {
opentimefood: '11:00~21:00<br>(준비 시간 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();
});
});
@@ -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<string, PlaceCategory> = {
VE040300: 'nature', // 둘레길
FD030100: 'cafe', // 제과(빵집) — 상가정보 빵/도넛과 같은 칸
};
/** 중분류 단위 */
const CATEGORY_BY_LCLS2: Record<string, PlaceCategory> = {
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<string, PlaceCategory> = {
NA: 'nature', // 자연관광
HS: 'attraction', // 역사관광
VE: 'attraction', // 문화관광(위 중분류에 없는 것)
EX: 'leisure', // 체험관광(공예·템플스테이·웰니스…)
LS: 'leisure', // 레저스포츠
SH: 'shopping', // 쇼핑(백화점·시장·사후면세점…)
FD: 'food', // 음식(카페·주점·제과 제외)
AC: 'lodging', // 숙박
};
/** 분류 코드가 비었을 때 관광타입으로 대신한다 */
const CATEGORY_BY_TYPE: Record<string, PlaceCategory> = {
'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<string, string> }
> = {
'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<string, string> | 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<string, string> = {};
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<string, string> | null;
publicPetPolicy: PublicPetPolicy | null;
}
/**
* 반려동물 동반 정보(detailPetTour2) → 원문 + 요약.
* 관광공사 정보는 "동반 가능한 곳"만 담는다 — 표시가 애매하면 요약하지 않고(null) 원문만 둔다.
*/
export function petToDetail(item: TourItem): TourPetDetail {
const raw: Record<string, string> = {};
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,
};
}
@@ -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<string, number>;
}
/**
* 관광공사 반려동물 동반 정보 반영. detailPetTour2 는 contentId 없이 부르면 전국 전체를 준다(약 10회).
* 받은 정보를 place(관광공사) 에 붙이고, 목록에서 빠진 장소는 정보를 지운다(끝까지 받았을 때만).
*/
@Injectable()
export class TourPetSync {
private readonly logger = new Logger(TourPetSync.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: TourApiFactory,
) {}
async run(): Promise<TourPetResult> {
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<string, TourPetDetail>();
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;
}
}
@@ -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('운영 환경', () => {
@@ -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 로 남아 있으면 안 되는 키 */
@@ -0,0 +1,40 @@
import { extractUrl, htmlToText } from './html-text';
describe('htmlToText', () => {
it('줄바꿈 태그는 줄바꿈으로, 나머지 태그는 지운다', () => {
expect(htmlToText('주차 가능<br>소형 20대<br />대형 2대')).toBe(
'주차 가능\n소형 20대\n대형 2대',
);
expect(htmlToText('<strong>무료</strong> 입장')).toBe('무료 입장');
});
it('자주 쓰는 HTML 엔티티를 되돌린다', () => {
expect(htmlToText('A &amp; B &lt;안내&gt;')).toBe('A & B <안내>');
});
it('빈 값·공백뿐인 값은 null 이다', () => {
expect(htmlToText('')).toBeNull();
expect(htmlToText(' <br> ')).toBeNull();
expect(htmlToText(undefined)).toBeNull();
});
});
describe('extractUrl', () => {
it('a 태그의 href 를 꺼낸다', () => {
expect(
extractUrl(
'<a href="https://tour.daegu.go.kr" target="_blank" title="새창">tour.daegu.go.kr</a>',
),
).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('<a href="javascript:alert(1)">x</a>')).toBeNull();
expect(extractUrl('홈페이지 없음')).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* 공공데이터 텍스트 정리 유틸 (순수 함수).
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
*/
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
'&nbsp;': ' ',
};
/**
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
* `<br>`·`</p>` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null.
*/
export function htmlToText(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const text = String(value)
.replace(/<br\s*\/?>/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 만 꺼낸다. 관광공사는 `<a href="…" …>…</a>` 를 통째로 준다.
* 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;
}
+38
View File
@@ -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();
});
});
+36
View File
@@ -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;
}
+44 -16
View File
@@ -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`