generated from ttipo/project-base
feat(backend): 상가정보 데이트 업종 적재 명령 추가
`npm run place:sync -- store [--signgu=...]` 로 소상공인 상가정보의 데이트 업종 6개(카페·빵/도넛·아이스크림/빙수·샌드위치/샐러드·요리 주점·생맥주)를 장소 DB 에 적재한다. 기본 범위는 1차 큐레이션 동네가 속한 대구 중구·남구·수성구다. - 분기마다 다시 실행하면, 끝까지 받은 (시군구, 업종) 묶음에서 사라진 업소를 gone(폐업 추정)으로 숨긴다. 지우지 않고, 결과가 기존의 80% 미만이면 건너뛴다 - 기준년월(stdrYm)을 source_snapshot 에 남긴다 - 공공데이터포털 공통 규칙(키 가림·간격·재시도·한도)을 PublicDataClient 로 묶어 관광공사·상가정보 클라이언트가 함께 쓴다(TourApiFactory → PublicDataApiFactory) 2026-11-02 대구 실측: 기준 2026-09, 3,776곳(18회 호출). 세 동네 반경 안 업소 수는 후보표(반경 API)와 거의 같다(도심 1,295/1,316 · 수성못 425/425 · 앞산 262/263). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 관광공사 API 하루 호출 한도 관리. 개발 계정은 **오퍼레이션마다** 하루 1,000건이다.
|
||||
* 공공데이터포털 API 하루 호출 한도 관리. 관광공사 개발 계정은 **오퍼레이션마다** 하루 1,000건, 상가정보는 10,000건이다.
|
||||
* 한도에 닿으면 실패가 아니라 "오늘은 여기까지" — 동기화는 진행 상황을 저장하고 멈춘다.
|
||||
*/
|
||||
|
||||
@@ -11,8 +11,8 @@ export class BudgetExceededError extends Error {
|
||||
) {
|
||||
super(
|
||||
reason === 'local'
|
||||
? `관광공사 ${operation} 오늘 호출 한도에 닿았습니다`
|
||||
: `관광공사 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
|
||||
? `공공데이터 ${operation} 오늘 호출 한도에 닿았습니다`
|
||||
: `공공데이터 ${operation} 호출이 서버에서 한도 초과로 거부되었습니다`,
|
||||
);
|
||||
this.name = 'BudgetExceededError';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import { Place } from '../entities/place.entity';
|
||||
import { SyncRunRecorder } from './sync-run.recorder';
|
||||
import { TourApiFactory } from './tour-api.factory';
|
||||
import { PublicDataApiFactory } from './public-data.factory';
|
||||
|
||||
/** 관광공사 국문 적재에 쓰는 오퍼레이션 */
|
||||
const TOUR_KOR_OPERATIONS = [
|
||||
@@ -21,9 +21,14 @@ export interface PlaceSyncStatusReport {
|
||||
withPet: number;
|
||||
usedToday: Record<string, number>;
|
||||
dailyLimit: number;
|
||||
store: {
|
||||
byStatus: Record<string, number>;
|
||||
byCategory: Record<string, number>;
|
||||
snapshot: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 적재 진행 상황 — 지역별 장소 수, 상세 남은 수, 오늘 쓴 호출 수 */
|
||||
/** 적재 진행 상황 — 지역별 장소·업소 수, 상세 남은 수, 오늘 쓴 호출 수 */
|
||||
@Injectable()
|
||||
export class PlaceSyncStatus {
|
||||
private readonly logger = new Logger(PlaceSyncStatus.name);
|
||||
@@ -31,16 +36,19 @@ export class PlaceSyncStatus {
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: TourApiFactory,
|
||||
private readonly apiFactory: PublicDataApiFactory,
|
||||
) {}
|
||||
|
||||
async report(regionCode: string): Promise<PlaceSyncStatusReport> {
|
||||
const count = async (column: 'status' | 'category') => {
|
||||
const count = async (
|
||||
column: 'status' | 'category',
|
||||
source: 'tour' | 'store' = 'tour',
|
||||
) => {
|
||||
const rows = await this.places
|
||||
.createQueryBuilder('p')
|
||||
.select(`p.${column}`, 'key')
|
||||
.addSelect('COUNT(*)', 'n')
|
||||
.where('p.source = :source', { source: 'tour' })
|
||||
.where('p.source = :source', { source })
|
||||
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
|
||||
.groupBy(`p.${column}`)
|
||||
.getRawMany<{ key: string; n: string }>();
|
||||
@@ -66,7 +74,12 @@ export class PlaceSyncStatus {
|
||||
detailPending: Number(pendingAndPet?.pending ?? 0),
|
||||
withPet: Number(pendingAndPet?.pet ?? 0),
|
||||
usedToday: await this.recorder.usedToday(),
|
||||
dailyLimit: this.apiFactory.dailyLimit(),
|
||||
dailyLimit: this.apiFactory.tourDailyLimit(),
|
||||
store: {
|
||||
byStatus: await count('status', 'store'),
|
||||
byCategory: await count('category', 'store'),
|
||||
snapshot: await this.storeSnapshot(regionCode),
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
@@ -80,6 +93,19 @@ export class PlaceSyncStatus {
|
||||
(op) => `${op} ${report.usedToday[op] ?? 0}/${report.dailyLimit}`,
|
||||
).join(' · ');
|
||||
this.logger.log(`오늘(KST) 이 저장소에서 쓴 호출 — ${used}`);
|
||||
this.logger.log(
|
||||
`상가정보 업소(기준 ${report.store.snapshot ?? '없음'}) — 상태 ${JSON.stringify(report.store.byStatus)} · 분류 ${JSON.stringify(report.store.byCategory)}`,
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
private async storeSnapshot(regionCode: string): Promise<string | null> {
|
||||
const row = await this.places
|
||||
.createQueryBuilder('p')
|
||||
.select('MAX(p.source_snapshot)', 'snapshot')
|
||||
.where('p.source = :source', { source: 'store' })
|
||||
.andWhere('p.ldong_regn_cd = :region', { region: regionCode })
|
||||
.getRawOne<{ snapshot: string | null }>();
|
||||
return row?.snapshot ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ 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 { PublicDataApiFactory } from './public-data.factory';
|
||||
import { TourKorSync } from './tour-kor.sync';
|
||||
import { StoreSync } from './store.sync';
|
||||
import { TourPetSync } from './tour-pet.sync';
|
||||
|
||||
/**
|
||||
@@ -16,12 +17,13 @@ import { TourPetSync } from './tour-pet.sync';
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Place, PlaceSyncRun])],
|
||||
providers: [
|
||||
TourApiFactory,
|
||||
PublicDataApiFactory,
|
||||
SyncRunRecorder,
|
||||
TourKorSync,
|
||||
TourPetSync,
|
||||
StoreSync,
|
||||
PlaceSyncStatus,
|
||||
],
|
||||
exports: [TourKorSync, TourPetSync, PlaceSyncStatus],
|
||||
exports: [TourKorSync, TourPetSync, StoreSync, PlaceSyncStatus],
|
||||
})
|
||||
export class PlaceSyncModule {}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
|
||||
/**
|
||||
* 공공데이터포털(apis.data.go.kr) 공통 호출 규칙 — 관광공사·상가정보 클라이언트가 함께 쓴다.
|
||||
* 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다), 간격을 두고, 네트워크 오류·5xx 만 다시 시도한다.
|
||||
* **키는 어떤 오류 메시지에도 남기지 않는다.** 응답 해석은 API 마다 달라 하위 클래스가 맡는다.
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비)
|
||||
|
||||
/** 게이트웨이가 한도 초과로 거부할 때의 표시(오류 코드 22) */
|
||||
export const QUOTA_EXCEEDED_RE =
|
||||
/LIMITED_NUMBER_OF_SERVICE_REQUESTS|<returnReasonCode>\s*22\s*</i;
|
||||
|
||||
export type PublicDataItem = Record<string, unknown>;
|
||||
|
||||
export class PublicDataApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PublicDataApiError';
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
export type FetchLike = (
|
||||
url: string,
|
||||
init: { signal: AbortSignal },
|
||||
) => Promise<FetchResponse>;
|
||||
|
||||
export interface PublicDataClientOptions {
|
||||
serviceKey: string;
|
||||
budget: CallBudget;
|
||||
baseUrl?: string;
|
||||
fetchImpl?: FetchLike;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 배열·객체 1건·빈 값 어느 쪽으로 와도 항목 배열로 바꾼다 */
|
||||
export function toItems(value: unknown): PublicDataItem[] {
|
||||
const list = Array.isArray(value) ? value : value ? [value] : [];
|
||||
return list.map(asRecord).filter((x): x is PublicDataItem => x !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 이 아닌 응답(키 오류·한도 초과 시 게이트웨이가 XML 로 답한다)을 오류로 바꾼다.
|
||||
* JSON 이면 파싱 결과를 돌려준다.
|
||||
*/
|
||||
export function parseJsonOrThrow(body: string, operation: string): unknown {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
if (QUOTA_EXCEEDED_RE.test(body)) {
|
||||
throw new BudgetExceededError(operation, 'server');
|
||||
}
|
||||
const reason = /<returnAuthMsg>([^<]+)</i.exec(body)?.[1];
|
||||
throw new PublicDataApiError(
|
||||
`${operation}: JSON 이 아닌 응답${reason ? ` (${reason})` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class PublicDataClient<Page> {
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
private lastCallAt = 0;
|
||||
|
||||
protected constructor(
|
||||
private readonly options: PublicDataClientOptions,
|
||||
private readonly baseUrl: string,
|
||||
private readonly commonParams: Record<string, string>,
|
||||
) {
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.sleep = options.sleep ?? defaultSleep;
|
||||
}
|
||||
|
||||
/** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */
|
||||
protected abstract parse(body: string, operation: string): Page;
|
||||
|
||||
async get(
|
||||
operation: string,
|
||||
params: Record<string, string | number>,
|
||||
): Promise<Page> {
|
||||
const query = new URLSearchParams({
|
||||
serviceKey: this.options.serviceKey,
|
||||
...this.commonParams,
|
||||
...Object.fromEntries(
|
||||
Object.entries(params).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
});
|
||||
const url = `${this.options.baseUrl ?? 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 PublicDataApiError(
|
||||
`${operation}: HTTP ${res.status}`,
|
||||
);
|
||||
} else if (!res.ok) {
|
||||
if (QUOTA_EXCEEDED_RE.test(body)) {
|
||||
throw new BudgetExceededError(operation, 'server');
|
||||
}
|
||||
throw new PublicDataApiError(`${operation}: HTTP ${res.status}`);
|
||||
} else {
|
||||
return this.parse(body, operation);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof BudgetExceededError ||
|
||||
error instanceof PublicDataApiError
|
||||
) {
|
||||
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 PublicDataApiError(
|
||||
`${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,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
import { CallBudget } from './call-budget';
|
||||
import { StoreApi, StoreApiClient } from './store-api.client';
|
||||
import { TourApi, TourApiClient } from './tour-api.client';
|
||||
|
||||
/** 관광공사 개발 계정의 오퍼레이션별 하루 한도. 운영 계정 승인 후 TOUR_API_DAILY_LIMIT 로 올린다 */
|
||||
const DEFAULT_TOUR_DAILY_LIMIT = 1_000;
|
||||
/** 상가정보 개발 계정 하루 한도(대구 3개 구 적재는 30회 안팎) */
|
||||
const STORE_DAILY_LIMIT = 10_000;
|
||||
/** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */
|
||||
const SAFETY_MARGIN = 20;
|
||||
|
||||
/**
|
||||
* 공공데이터포털 클라이언트를 만든다(키 하나로 관광공사·상가정보를 모두 부른다).
|
||||
* 테스트에서는 이 프로바이더를 가짜로 바꿔 끼운다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PublicDataApiFactory {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/** 관광공사 오퍼레이션별 이번 실행 상한(하루 한도 − 여유분) */
|
||||
tourDailyLimit(): number {
|
||||
const limit = parsePositiveInt(
|
||||
this.config.get<string>('TOUR_API_DAILY_LIMIT'),
|
||||
DEFAULT_TOUR_DAILY_LIMIT,
|
||||
);
|
||||
return Math.max(0, limit - SAFETY_MARGIN);
|
||||
}
|
||||
|
||||
storeDailyLimit(): number {
|
||||
return STORE_DAILY_LIMIT - SAFETY_MARGIN;
|
||||
}
|
||||
|
||||
createTour(budget: CallBudget): TourApi {
|
||||
return new TourApiClient({ serviceKey: this.serviceKey(), budget });
|
||||
}
|
||||
|
||||
createStore(budget: CallBudget): StoreApi {
|
||||
return new StoreApiClient({ serviceKey: this.serviceKey(), budget });
|
||||
}
|
||||
|
||||
private serviceKey(): string {
|
||||
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 key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BudgetExceededError } from './call-budget';
|
||||
import { PublicDataApiError } from './public-data.client';
|
||||
import { parseStorePage } from './store-api.client';
|
||||
|
||||
const store = {
|
||||
bizesId: 'MA0101202210A0001234',
|
||||
bizesNm: '어느카페',
|
||||
indsSclsCd: 'I21201',
|
||||
lon: '128.5940',
|
||||
lat: '35.8702',
|
||||
};
|
||||
|
||||
describe('parseStorePage', () => {
|
||||
it('최상위 header·body 구조에서 목록·총건수·기준년월을 읽는다', () => {
|
||||
const body = JSON.stringify({
|
||||
header: {
|
||||
resultCode: '00',
|
||||
resultMsg: 'NORMAL SERVICE',
|
||||
stdrYm: '202509',
|
||||
},
|
||||
body: { items: [store], numOfRows: 1000, pageNo: 1, totalCount: 1 },
|
||||
});
|
||||
expect(parseStorePage(body, 'storeListInDong')).toEqual({
|
||||
items: [store],
|
||||
totalCount: 1,
|
||||
snapshot: '202509',
|
||||
});
|
||||
});
|
||||
|
||||
it('response 로 감싸 오거나 item 아래에 있어도 읽는다', () => {
|
||||
const body = JSON.stringify({
|
||||
response: {
|
||||
header: { resultCode: '00', stdrYm: '202509' },
|
||||
body: { items: { item: store }, totalCount: 1 },
|
||||
},
|
||||
});
|
||||
expect(parseStorePage(body, 'storeListInDong').items).toEqual([store]);
|
||||
});
|
||||
|
||||
it('데이터 없음(03)은 오류가 아니라 빈 목록이다', () => {
|
||||
const body = JSON.stringify({
|
||||
header: { resultCode: '03', resultMsg: 'NODATA_ERROR' },
|
||||
});
|
||||
expect(parseStorePage(body, 'storeListInDong')).toEqual({
|
||||
items: [],
|
||||
totalCount: 0,
|
||||
snapshot: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('한도 초과(22)는 한도 오류, 그 밖의 코드는 일반 오류다', () => {
|
||||
const quota = JSON.stringify({ header: { resultCode: '22' } });
|
||||
expect(() => parseStorePage(quota, 'storeListInDong')).toThrow(
|
||||
BudgetExceededError,
|
||||
);
|
||||
const bad = JSON.stringify({
|
||||
header: {
|
||||
resultCode: '10',
|
||||
resultMsg: 'INVALID_REQUEST_PARAMETER_ERROR',
|
||||
},
|
||||
});
|
||||
expect(() => parseStorePage(bad, 'storeListInDong')).toThrow(
|
||||
PublicDataApiError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BudgetExceededError } from './call-budget';
|
||||
import {
|
||||
PublicDataApiError,
|
||||
PublicDataClient,
|
||||
PublicDataClientOptions,
|
||||
PublicDataItem,
|
||||
asRecord,
|
||||
parseJsonOrThrow,
|
||||
toItems,
|
||||
} from './public-data.client';
|
||||
|
||||
/** 소상공인시장진흥공단 상가(상권)정보 */
|
||||
export const STORE_API_BASE_URL =
|
||||
'https://apis.data.go.kr/B553077/api/open/sdsc2';
|
||||
|
||||
/** 결과 코드: 00 정상 · 03 데이터 없음 · 22 한도 초과 */
|
||||
const OK = '00';
|
||||
const NO_DATA = '03';
|
||||
const QUOTA_EXCEEDED = '22';
|
||||
|
||||
export type StoreItem = PublicDataItem;
|
||||
|
||||
export interface StorePage {
|
||||
items: StoreItem[];
|
||||
totalCount: number;
|
||||
/** 기준년월(header.stdrYm, 예 '202509') — 분기마다 바뀐다 */
|
||||
snapshot: string | null;
|
||||
}
|
||||
|
||||
/** 동기화가 쓰는 상가정보 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
|
||||
export interface StoreApi {
|
||||
get(
|
||||
operation: string,
|
||||
params: Record<string, string | number>,
|
||||
): Promise<StorePage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상가정보 응답 → 목록. 관광공사와 달리 `{ header, body: { items: [...] } }` 가 최상위에 온다
|
||||
* (혹시 response 로 감싸 와도 읽는다). 데이터 없음(03)은 오류가 아니라 빈 목록이다.
|
||||
*/
|
||||
export function parseStorePage(body: string, operation: string): StorePage {
|
||||
const root = asRecord(parseJsonOrThrow(body, operation));
|
||||
const wrapper = asRecord(root?.response) ?? root;
|
||||
const header = asRecord(wrapper?.header);
|
||||
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
|
||||
const snapshot =
|
||||
typeof header?.stdrYm === 'string' && header.stdrYm.trim() !== ''
|
||||
? header.stdrYm.trim()
|
||||
: null;
|
||||
if (code === NO_DATA) return { items: [], totalCount: 0, snapshot };
|
||||
if (code !== '' && code !== OK) {
|
||||
if (code === QUOTA_EXCEEDED) {
|
||||
throw new BudgetExceededError(operation, 'server');
|
||||
}
|
||||
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
|
||||
throw new PublicDataApiError(
|
||||
`${operation}: 결과 코드 ${code} ${msg}`.trim(),
|
||||
);
|
||||
}
|
||||
const resBody = asRecord(wrapper?.body);
|
||||
const rawItems = resBody?.items;
|
||||
const items = toItems(asRecord(rawItems)?.item ?? rawItems);
|
||||
const totalCount = Number(resBody?.totalCount ?? items.length);
|
||||
return {
|
||||
items,
|
||||
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export class StoreApiClient
|
||||
extends PublicDataClient<StorePage>
|
||||
implements StoreApi
|
||||
{
|
||||
constructor(options: PublicDataClientOptions) {
|
||||
super(options, STORE_API_BASE_URL, { type: 'json' });
|
||||
}
|
||||
|
||||
protected parse(body: string, operation: string): StorePage {
|
||||
return parseStorePage(body, operation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { storeItemToPlace } from './store-mapper';
|
||||
|
||||
const item = (over: Record<string, unknown> = {}) => ({
|
||||
bizesId: 'MA0101202210A0001234',
|
||||
bizesNm: '어느카페',
|
||||
brchNm: '동성로점',
|
||||
indsSclsCd: 'I21201',
|
||||
indsSclsNm: '카페',
|
||||
ctprvnCd: '27',
|
||||
signguCd: '27110',
|
||||
adongCd: '27110565',
|
||||
adongNm: '성내1동',
|
||||
rdnmAdr: '대구광역시 중구 동성로2길 1',
|
||||
lnoAdr: '대구광역시 중구 삼덕동1가 1',
|
||||
lon: '128.5940',
|
||||
lat: '35.8702',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('storeItemToPlace', () => {
|
||||
it('업소를 place 기본 컬럼으로 바꾼다(lon=경도, lat=위도, 시군구 5자리는 둘로 나눔)', () => {
|
||||
expect(storeItemToPlace(item(), '202509')).toEqual({
|
||||
source: 'store',
|
||||
sourceId: 'MA0101202210A0001234',
|
||||
category: 'cafe',
|
||||
sourceCategory: 'I21201',
|
||||
name: '어느카페',
|
||||
branchName: '동성로점',
|
||||
addressRoad: '대구광역시 중구 동성로2길 1',
|
||||
addressJibun: '대구광역시 중구 삼덕동1가 1',
|
||||
lat: 35.8702,
|
||||
lng: 128.594,
|
||||
ldongRegnCd: '27',
|
||||
ldongSignguCd: '110',
|
||||
adongCd: '27110565',
|
||||
sourceSnapshot: '202509',
|
||||
});
|
||||
});
|
||||
|
||||
it('요리 주점·생맥주는 bar, 빵/도넛은 cafe 다', () => {
|
||||
expect(
|
||||
storeItemToPlace(item({ indsSclsCd: 'I21104' }), null),
|
||||
).toMatchObject({ category: 'bar' });
|
||||
expect(
|
||||
storeItemToPlace(item({ indsSclsCd: 'I21001' }), null),
|
||||
).toMatchObject({ category: 'cafe' });
|
||||
});
|
||||
|
||||
it('데이트 업종이 아니거나 좌표·ID 가 없으면 건너뛴다', () => {
|
||||
expect(storeItemToPlace(item({ indsSclsCd: 'I20101' }), null)).toEqual({
|
||||
skip: 'no_category',
|
||||
});
|
||||
expect(storeItemToPlace(item({ lat: '', lon: '' }), null)).toEqual({
|
||||
skip: 'bad_coords',
|
||||
});
|
||||
expect(storeItemToPlace(item({ bizesId: '' }), null)).toEqual({
|
||||
skip: 'no_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isInKorea } from '@/shared/utils/geo';
|
||||
import { htmlToText } from '@/shared/utils/html-text';
|
||||
import type { PlaceCategory } from '../place.constants';
|
||||
import type { StoreItem } from './store-api.client';
|
||||
|
||||
/**
|
||||
* 상가정보 업소 → place 컬럼 변환 (순수 함수).
|
||||
* 적재하는 업종은 데이트 업종 소분류 6개뿐이다(2026-10-29 점검에서 정함, 유흥주점 제외).
|
||||
* 영업시간·전화·반려동물 정보는 상가정보에 없다 — 앱에는 "영업시간 미확인"으로 나가고 큐레이션으로 채운다.
|
||||
*/
|
||||
export const STORE_CATEGORY_BY_CODE: Record<string, PlaceCategory> = {
|
||||
I21201: 'cafe', // 카페
|
||||
I21001: 'cafe', // 빵/도넛
|
||||
I21008: 'cafe', // 아이스크림/빙수
|
||||
I21005: 'cafe', // 샌드위치/샐러드
|
||||
I21104: 'bar', // 요리 주점
|
||||
I21103: 'bar', // 생맥주 전문
|
||||
};
|
||||
|
||||
export const STORE_DATE_CODES = Object.keys(STORE_CATEGORY_BY_CODE);
|
||||
|
||||
export interface StorePlace {
|
||||
source: 'store';
|
||||
sourceId: string;
|
||||
category: PlaceCategory;
|
||||
sourceCategory: string;
|
||||
name: string;
|
||||
branchName: string | null;
|
||||
addressRoad: string | null;
|
||||
addressJibun: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
ldongRegnCd: string | null;
|
||||
ldongSignguCd: string | null;
|
||||
adongCd: string | null;
|
||||
sourceSnapshot: string | null;
|
||||
}
|
||||
|
||||
export type StoreSkipReason = 'no_id' | 'no_category' | 'bad_coords';
|
||||
|
||||
function str(item: StoreItem, 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;
|
||||
}
|
||||
|
||||
/** 업소 1건 → place 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */
|
||||
export function storeItemToPlace(
|
||||
item: StoreItem,
|
||||
snapshot: string | null,
|
||||
): StorePlace | { skip: StoreSkipReason } {
|
||||
const sourceId = str(item, 'bizesId');
|
||||
const name = htmlToText(item.bizesNm);
|
||||
if (!sourceId || !name) return { skip: 'no_id' };
|
||||
const code = str(item, 'indsSclsCd');
|
||||
const category = code ? STORE_CATEGORY_BY_CODE[code] : undefined;
|
||||
if (!code || !category) return { skip: 'no_category' };
|
||||
const lat = Number(str(item, 'lat'));
|
||||
const lng = Number(str(item, 'lon'));
|
||||
if (!isInKorea(lat, lng)) return { skip: 'bad_coords' };
|
||||
|
||||
// 시군구 코드는 5자리(시도 2 + 시군구 3) — place 는 법정동 체계처럼 둘로 나눠 둔다
|
||||
const signgu = str(item, 'signguCd');
|
||||
return {
|
||||
source: 'store',
|
||||
sourceId,
|
||||
category,
|
||||
sourceCategory: code,
|
||||
name,
|
||||
branchName: htmlToText(item.brchNm),
|
||||
addressRoad: str(item, 'rdnmAdr'),
|
||||
addressJibun: str(item, 'lnoAdr'),
|
||||
lat,
|
||||
lng,
|
||||
ldongRegnCd: str(item, 'ctprvnCd') ?? signgu?.slice(0, 2) ?? null,
|
||||
ldongSignguCd: signgu && signgu.length === 5 ? signgu.slice(2) : null,
|
||||
adongCd: str(item, 'adongCd'),
|
||||
sourceSnapshot: snapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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 { PublicDataApiFactory } from './public-data.factory';
|
||||
import type { StoreItem } from './store-api.client';
|
||||
import {
|
||||
STORE_DATE_CODES,
|
||||
StorePlace,
|
||||
StoreSkipReason,
|
||||
storeItemToPlace,
|
||||
} from './store-mapper';
|
||||
import { SyncRunRecorder } from './sync-run.recorder';
|
||||
|
||||
/** 대구 중구·남구·수성구 — 1차 큐레이션 동네(도심·앞산·수성못)가 속한 구 */
|
||||
export const DEFAULT_STORE_SIGNGU = ['27110', '27200', '27260'];
|
||||
|
||||
const PAGE_SIZE = 1_000; // 매뉴얼 최대값
|
||||
const MAX_PAGES = 30;
|
||||
const UPSERT_CHUNK = 200;
|
||||
/** 이번에 받은 업소가 기존 노출 업소보다 이만큼 적으면 원본 장애로 보고 gone 처리를 하지 않는다 */
|
||||
const MIN_COMPLETE_RATIO = 0.8;
|
||||
|
||||
/** upsert 때 덮어쓰는 컬럼 — 큐레이션은 다른 테이블이라 건드리지 않는다 */
|
||||
const OVERWRITE_PROPS = [
|
||||
'category',
|
||||
'sourceCategory',
|
||||
'name',
|
||||
'branchName',
|
||||
'addressRoad',
|
||||
'addressJibun',
|
||||
'lat',
|
||||
'lng',
|
||||
'ldongRegnCd',
|
||||
'ldongSignguCd',
|
||||
'adongCd',
|
||||
'sourceSnapshot',
|
||||
'status',
|
||||
'lastSeenAt',
|
||||
'updatedAt',
|
||||
] as const satisfies readonly (keyof Place)[];
|
||||
|
||||
export interface StoreGroupResult {
|
||||
signgu: string;
|
||||
code: string;
|
||||
fetched: number;
|
||||
totalCount: number;
|
||||
complete: boolean;
|
||||
gone: number;
|
||||
}
|
||||
|
||||
export interface StoreSyncResult {
|
||||
fetched: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
gone: number;
|
||||
skipped: Partial<Record<StoreSkipReason, number>>;
|
||||
snapshot: string | null;
|
||||
groups: StoreGroupResult[];
|
||||
stoppedByBudget: boolean;
|
||||
calls: Record<string, number>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 소상공인 상가정보 동기화(분기마다). 시군구 × 데이트 업종 소분류로 storeListInDong 을 받아 place 에 upsert 한다.
|
||||
* 다음 분기에 사라진 업소는 gone(폐업 추정)으로 숨긴다 — 지우지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StoreSync {
|
||||
private readonly logger = new Logger(StoreSync.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: PublicDataApiFactory,
|
||||
) {}
|
||||
|
||||
async run(options: { signguCodes: string[] }): Promise<StoreSyncResult> {
|
||||
const budget = new CallBudget(
|
||||
this.apiFactory.storeDailyLimit(),
|
||||
await this.recorder.usedToday(),
|
||||
);
|
||||
const api = this.apiFactory.createStore(budget);
|
||||
const run = await this.recorder.start(
|
||||
'store',
|
||||
options.signguCodes.join(','),
|
||||
);
|
||||
const result: StoreSyncResult = {
|
||||
fetched: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
gone: 0,
|
||||
skipped: {},
|
||||
snapshot: null,
|
||||
groups: [],
|
||||
stoppedByBudget: false,
|
||||
calls: {},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const runStart = new Date();
|
||||
const existing = await this.places.find({
|
||||
where: { source: 'store' },
|
||||
select: {
|
||||
id: true,
|
||||
sourceId: true,
|
||||
status: true,
|
||||
ldongRegnCd: true,
|
||||
ldongSignguCd: true,
|
||||
sourceCategory: true,
|
||||
},
|
||||
});
|
||||
const known = new Set(existing.map((p) => p.sourceId));
|
||||
const rows = new Map<string, StorePlace & Partial<Place>>();
|
||||
const rowsPerGroup = new Map<string, number>();
|
||||
|
||||
outer: for (const signgu of options.signguCodes) {
|
||||
for (const code of STORE_DATE_CODES) {
|
||||
const group: StoreGroupResult = {
|
||||
signgu,
|
||||
code,
|
||||
fetched: 0,
|
||||
totalCount: 0,
|
||||
complete: false,
|
||||
gone: 0,
|
||||
};
|
||||
result.groups.push(group);
|
||||
let kept = 0;
|
||||
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
||||
let items: StoreItem[];
|
||||
try {
|
||||
const res = await api.get('storeListInDong', {
|
||||
divId: 'signguCd',
|
||||
key: signgu,
|
||||
indsSclsCd: code,
|
||||
numOfRows: PAGE_SIZE,
|
||||
pageNo: page,
|
||||
});
|
||||
items = res.items;
|
||||
group.totalCount = res.totalCount;
|
||||
result.snapshot = res.snapshot ?? result.snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof BudgetExceededError) {
|
||||
result.stoppedByBudget = true;
|
||||
result.errors.push(error.message);
|
||||
break outer;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
group.fetched += items.length;
|
||||
result.fetched += items.length;
|
||||
for (const item of items) {
|
||||
const mapped = storeItemToPlace(item, result.snapshot);
|
||||
if ('skip' in mapped) {
|
||||
result.skipped[mapped.skip] =
|
||||
(result.skipped[mapped.skip] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (!rows.has(mapped.sourceId)) {
|
||||
rows.set(mapped.sourceId, {
|
||||
...mapped,
|
||||
status: 'active',
|
||||
lastSeenAt: runStart,
|
||||
updatedAt: runStart,
|
||||
});
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
if (items.length === 0 || group.fetched >= group.totalCount) {
|
||||
group.complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
rowsPerGroup.set(`${signgu}/${code}`, kept);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of rows.keys()) {
|
||||
if (known.has(id)) result.updated += 1;
|
||||
else result.inserted += 1;
|
||||
}
|
||||
await this.upsert([...rows.values()]);
|
||||
await this.markGone(existing, result, rowsPerGroup, runStart);
|
||||
} 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,
|
||||
gone: result.gone,
|
||||
calls: result.calls,
|
||||
snapshot: result.snapshot,
|
||||
errors: result.errors,
|
||||
});
|
||||
this.report(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async upsert(rows: Partial<Place>[]): Promise<void> {
|
||||
const overwrite = OVERWRITE_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();
|
||||
}
|
||||
}
|
||||
|
||||
/** 끝까지 받은 (시군구, 업종) 묶음에서만, 이번에 보이지 않은 노출 업소를 gone 으로 바꾼다 */
|
||||
private async markGone(
|
||||
existing: Place[],
|
||||
result: StoreSyncResult,
|
||||
rowsPerGroup: Map<string, number>,
|
||||
runStart: Date,
|
||||
): Promise<void> {
|
||||
for (const group of result.groups.filter((g) => g.complete)) {
|
||||
const regn = group.signgu.slice(0, 2);
|
||||
const signgu = group.signgu.slice(2);
|
||||
const activeBefore = existing.filter(
|
||||
(p) =>
|
||||
p.status === 'active' &&
|
||||
p.ldongRegnCd === regn &&
|
||||
p.ldongSignguCd === signgu &&
|
||||
p.sourceCategory === group.code,
|
||||
).length;
|
||||
const seen = rowsPerGroup.get(`${group.signgu}/${group.code}`) ?? 0;
|
||||
if (activeBefore > 0 && seen < activeBefore * MIN_COMPLETE_RATIO) {
|
||||
result.errors.push(
|
||||
`${group.signgu}/${group.code}: 이번 결과(${seen})가 기존(${activeBefore})보다 크게 적어 폐업 처리를 건너뜀`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const updated = await this.places
|
||||
.createQueryBuilder()
|
||||
.update(Place)
|
||||
.set({ status: 'gone' })
|
||||
.where('source = :source', { source: 'store' })
|
||||
.andWhere('ldong_regn_cd = :regn', { regn })
|
||||
.andWhere('ldong_signgu_cd = :signgu', { signgu })
|
||||
.andWhere('source_category = :code', { code: group.code })
|
||||
.andWhere('status = :active', { active: 'active' })
|
||||
.andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', {
|
||||
runStart,
|
||||
})
|
||||
.execute();
|
||||
group.gone = updated.affected ?? 0;
|
||||
result.gone += group.gone;
|
||||
}
|
||||
}
|
||||
|
||||
private report(r: StoreSyncResult): void {
|
||||
const skipped = Object.entries(r.skipped)
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(', ');
|
||||
this.logger.log(
|
||||
`상가정보(기준 ${r.snapshot ?? '?'}) ${r.fetched}건 → 새 업소 ${r.inserted} · 갱신 ${r.updated} · 폐업 추정 ${r.gone}` +
|
||||
(skipped ? ` · 건너뜀(${skipped})` : ''),
|
||||
);
|
||||
const incomplete = r.groups.filter((g) => !g.complete);
|
||||
if (incomplete.length > 0 || r.stoppedByBudget) {
|
||||
this.logger.warn(
|
||||
`끝까지 받지 못한 묶음 ${incomplete.length}개 — 그 묶음은 폐업 처리를 하지 않았다`,
|
||||
);
|
||||
}
|
||||
this.logger.log(`호출 수 ${JSON.stringify(r.calls)}`);
|
||||
for (const e of r.errors.slice(0, 5)) this.logger.warn(e);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
import {
|
||||
FetchLike,
|
||||
TourApiClient,
|
||||
TourApiError,
|
||||
parseTourPage,
|
||||
} from './tour-api.client';
|
||||
import { FetchLike, PublicDataApiError } from './public-data.client';
|
||||
import { TourApiClient, parseTourPage } from './tour-api.client';
|
||||
|
||||
const KEY = 'abc+def/ghi==';
|
||||
|
||||
@@ -126,7 +122,7 @@ describe('TourApiClient', () => {
|
||||
},
|
||||
]);
|
||||
const error = await api.get('detailCommon2', {}).catch((e: Error) => e);
|
||||
expect(error).toBeInstanceOf(TourApiError);
|
||||
expect(error).toBeInstanceOf(PublicDataApiError);
|
||||
expect((error as Error).message).toContain('***');
|
||||
expect((error as Error).message).not.toContain(KEY);
|
||||
expect((error as Error).message).not.toContain(encodeURIComponent(KEY));
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { BudgetExceededError, CallBudget } from './call-budget';
|
||||
import { BudgetExceededError } from './call-budget';
|
||||
import {
|
||||
PublicDataApiError,
|
||||
PublicDataClient,
|
||||
PublicDataClientOptions,
|
||||
PublicDataItem,
|
||||
asRecord,
|
||||
parseJsonOrThrow,
|
||||
toItems,
|
||||
} from './public-data.client';
|
||||
|
||||
/** 관광공사 국문 관광정보 서비스(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; // 연속 호출 간격(서버 부담·초당 제한 대비)
|
||||
const TOUR_COMMON_PARAMS = {
|
||||
MobileOS: 'ETC',
|
||||
MobileApp: 'WanderGo',
|
||||
_type: 'json',
|
||||
};
|
||||
|
||||
/** 공공데이터포털이 한도 초과로 거부할 때의 표시(오류 코드 22) */
|
||||
const QUOTA_EXCEEDED_RE =
|
||||
/LIMITED_NUMBER_OF_SERVICE_REQUESTS|<returnReasonCode>\s*22\s*</i;
|
||||
|
||||
export type TourItem = Record<string, unknown>;
|
||||
export type TourItem = PublicDataItem;
|
||||
|
||||
export interface TourPage {
|
||||
items: TourItem[];
|
||||
@@ -28,70 +33,25 @@ export interface TourApi {
|
||||
): 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 로 답한다.
|
||||
* 관광공사 응답 → 목록. `{ response: { header, body: { items: { item } } } }` 구조이고,
|
||||
* 항목이 1건이면 배열 대신 객체를, 0건이면 빈 문자열을 준다. 정상 코드는 '0000'.
|
||||
*/
|
||||
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 response = asRecord(
|
||||
asRecord(parseJsonOrThrow(body, operation))?.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());
|
||||
throw new PublicDataApiError(
|
||||
`${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 items = toItems(asRecord(resBody?.items)?.item);
|
||||
const totalCount = Number(resBody?.totalCount ?? items.length);
|
||||
return {
|
||||
items,
|
||||
@@ -99,84 +59,15 @@ export function parseTourPage(body: string, operation: string): TourPage {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 관광공사 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;
|
||||
export class TourApiClient
|
||||
extends PublicDataClient<TourPage>
|
||||
implements TourApi
|
||||
{
|
||||
constructor(options: PublicDataClientOptions) {
|
||||
super(options, TOUR_API_BASE_URL, TOUR_COMMON_PARAMS);
|
||||
}
|
||||
|
||||
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;
|
||||
protected parse(body: string, operation: string): TourPage {
|
||||
return parseTourPage(body, operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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 { PublicDataApiFactory } from './public-data.factory';
|
||||
import {
|
||||
INTRO_FIELDS,
|
||||
SkipReason,
|
||||
@@ -97,16 +97,16 @@ export class TourKorSync {
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: TourApiFactory,
|
||||
private readonly apiFactory: PublicDataApiFactory,
|
||||
) {}
|
||||
|
||||
async run(options: TourKorOptions): Promise<TourKorResult> {
|
||||
const { regionCode } = options;
|
||||
const budget = new CallBudget(
|
||||
this.apiFactory.dailyLimit(),
|
||||
this.apiFactory.tourDailyLimit(),
|
||||
await this.recorder.usedToday(),
|
||||
);
|
||||
const api = this.apiFactory.create(budget);
|
||||
const api = this.apiFactory.createTour(budget);
|
||||
const run = await this.recorder.start('tour_kor', regionCode);
|
||||
const result: TourKorResult = {
|
||||
fetched: 0,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isInKorea } from '@/shared/utils/geo';
|
||||
import { extractUrl, htmlToText } from '@/shared/utils/html-text';
|
||||
import { parseKstTimestamp } from '@/shared/utils/kst';
|
||||
import { parseOpeningHours } from '@/shared/utils/opening-hours';
|
||||
@@ -124,9 +125,6 @@ export const INTRO_FIELDS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/** 한국 영역 대략값 — 좌표가 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;
|
||||
@@ -189,15 +187,7 @@ export function listItemToPlace(
|
||||
|
||||
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
|
||||
) {
|
||||
if (!isInKorea(lat, lng)) {
|
||||
return { skip: 'bad_coords' };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { PublicDataApiFactory } from './public-data.factory';
|
||||
import { TourPetDetail, petToDetail } from './tour-mapper';
|
||||
|
||||
const PAGE_SIZE = 1_000;
|
||||
@@ -32,15 +32,15 @@ export class TourPetSync {
|
||||
constructor(
|
||||
@InjectRepository(Place) private readonly places: Repository<Place>,
|
||||
private readonly recorder: SyncRunRecorder,
|
||||
private readonly apiFactory: TourApiFactory,
|
||||
private readonly apiFactory: PublicDataApiFactory,
|
||||
) {}
|
||||
|
||||
async run(): Promise<TourPetResult> {
|
||||
const budget = new CallBudget(
|
||||
this.apiFactory.dailyLimit(),
|
||||
this.apiFactory.tourDailyLimit(),
|
||||
await this.recorder.usedToday(),
|
||||
);
|
||||
const api = this.apiFactory.create(budget);
|
||||
const api = this.apiFactory.createTour(budget);
|
||||
const run = await this.recorder.start('tour_pet', null);
|
||||
const result: TourPetResult = {
|
||||
fetched: 0,
|
||||
|
||||
Reference in New Issue
Block a user