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

Merged
ttipo merged 30 commits from feat/place-db into main 2026-11-04 13:25:51 +09:00
20 changed files with 953 additions and 226 deletions
Showing only changes of commit 71aaf0bcae - Show all commits
+28 -4
View File
@@ -4,6 +4,10 @@ import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status';
import {
DEFAULT_STORE_SIGNGU,
StoreSync,
} from '@/modules/place/sync/store.sync';
import {
DEFAULT_TOUR_REGION,
TourKorSync,
@@ -24,16 +28,19 @@ const USAGE = `사용법: place:sync <작업> [옵션]
--region=27 법정동 시·도 코드 (기본 27 대구)
--skip-details 목록만 갱신
tour-pet 관광공사 반려동물 동반 정보 반영 (전국 약 10회 호출)
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
status 지역별 적재 현황과 오늘 쓴 호출 수
--region=27`;
const JOBS = ['tour-kor', 'tour-pet', 'status'] as const;
const JOBS = ['tour-kor', 'tour-pet', 'store', 'status'] as const;
type Job = (typeof JOBS)[number];
function parseArgs(argv: string[]): {
job: Job | null;
region: string;
skipDetails: boolean;
signgu: string[];
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
@@ -41,12 +48,27 @@ function parseArgs(argv: string[]): {
: 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') };
const signguArg = rest.find((a) => a.startsWith('--signgu='));
const signgu = signguArg
? signguArg
.slice('--signgu='.length)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_STORE_SIGNGU;
return {
job,
region,
skipDetails: rest.includes('--skip-details'),
signgu,
};
}
async function main(): Promise<void> {
const { job, region, skipDetails } = parseArgs(process.argv.slice(2));
if (!job || !/^\d{2}$/.test(region)) {
const { job, region, skipDetails, signgu } = parseArgs(process.argv.slice(2));
const validSigngu =
signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v));
if (!job || !/^\d{2}$/.test(region) || !validSigngu) {
Logger.error(USAGE, 'PlaceSync');
process.exitCode = 1;
return;
@@ -60,6 +82,8 @@ async function main(): Promise<void> {
await app.get(TourKorSync).run({ regionCode: region, skipDetails });
} else if (job === 'tour-pet') {
await app.get(TourPetSync).run();
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else {
await app.get(PlaceSyncStatus).report(region);
}
@@ -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));
+33 -142
View File
@@ -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,
+2 -12
View File
@@ -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,
+16
View File
@@ -0,0 +1,16 @@
import { isInKorea } from './geo';
describe('isInKorea', () => {
it('대구 좌표는 한국 안이다', () => {
expect(isInKorea(35.8677, 128.5867)).toBe(true);
});
it('위도·경도가 뒤바뀌었거나 0 이면 한국 밖으로 본다', () => {
expect(isInKorea(128.5867, 35.8677)).toBe(false);
expect(isInKorea(0, 0)).toBe(false);
});
it('숫자가 아니면 거른다', () => {
expect(isInKorea(Number.NaN, 128.5)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* 좌표 유틸 (순수 함수).
*/
/** 한국 영역 대략값(제주~백령도·독도 포함) */
const KOREA_BOUNDS = { latMin: 33, latMax: 39.5, lngMin: 124, lngMax: 132 };
/**
* 위도·경도가 한국 안의 정상 좌표인지 본다.
* 공공데이터에는 좌표가 0 이거나 위도·경도가 뒤바뀐 항목이 섞여 있다.
*/
export function isInKorea(lat: number, lng: number): boolean {
const b = KOREA_BOUNDS;
return (
Number.isFinite(lat) &&
Number.isFinite(lng) &&
lat >= b.latMin &&
lat <= b.latMax &&
lng >= b.lngMin &&
lng <= b.lngMax
);
}
+9 -4
View File
@@ -1,7 +1,7 @@
# 장소 DB 설계 (v1.2, 2026-11-02)
# 장소 DB 설계 (v1.3, 2026-11-02)
WanderGo 의 장소·축제 데이터를 어떻게 저장하고, 갱신하고, 앱에 노출하는지 정한다.
1단계(엔티티·마이그레이션)와 2단계(관광공사 국문·반려동물 동기화 명령)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`.
1단계(엔티티·마이그레이션)와 2·3단계(관광공사 국문·반려동물·상가정보 동기화 명령)를 구현했다 — `backend/src/modules/place/`, `backend/src/commands/place-sync.ts`, `backend/src/migrations/`.
이 문서와 코드가 다르면 코드가 기준이다. 바꿀 때는 이 문서도 함께 고친다.
## 0. 전제 (이미 정한 것)
@@ -238,13 +238,14 @@ erDiagram
- 비표출 레코드는 법정동이 비어 있으므로 비표출 확인은 지역 필터 없이 한다(매뉴얼 검토 노트).
- 동기화는 `place` 만 쓴다. `place_curation` 은 읽지도 쓰지도 않는다.
### 4.1 실행 방법 (구현됨: 관광공사 국문·반려동물)
### 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 -- store # 상가정보 데이트 업종(대구 중구·남구·수성구, 30회 안팎). 분기마다
dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수
# 다음 날 같은 tour-kor 명령을 다시 실행하면 남은 상세를 이어서 받는다
@@ -257,6 +258,10 @@ node dist/commands/place-sync.js tour-kor
- 상세는 nature → attraction → culture → cafe → food … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터).
- 목록을 끝까지 받았을 때만 사라진 장소를 숨기고, 목록이 기존 노출 장소의 80% 미만이면 원본 장애로 보고 숨기지 않는다.
- 원본 수정일(modifiedtime)이 바뀐 장소는 `detail_synced_at` 을 비워 상세를 다시 받는다.
- 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경).
끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾸고, 결과가 기존의 80% 미만이면 건너뛴다.
업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다.
- 2026-11-02 임시 DB + 가짜 상가정보 API 로 확인: 첫 적재 → 다음 분기 폐업 추정·업종 변경·신규 → 급감 시 폐업 처리 건너뜀 → 한도 0 이면 아무것도 바꾸지 않음.
- 2026-11-02 임시 DB + 가짜 API 로 확인한 흐름: 한도 3회로 멈춤 → 같은 날 재실행 시 추가 호출 없음 → 다음 날 이어받기 → 수정 1곳만 재조회·사라진 1곳 숨김 → 반려동물 반영.
## 5. 위치 검색
@@ -304,7 +309,7 @@ node dist/commands/place-sync.js tour-kor
1. ✅ 엔티티 + 마이그레이션: `place`, `place_curation`, `place_i18n`, `festival_event`, `place_sync_run`, `place_listing` 뷰 (2026-11-02)
2. ✅ 관광공사 국문 적재 명령(대구) + 반려동물 동기화 + 현황 명령 (2026-11-02, 실제 적재는 개발 계정으로 2일)
3. 상가정보 적재 명령(대상 행정동)
3. ✅ 상가정보 적재 명령(대구 중구·남구·수성구 데이트 업종) (2026-11-02)
4. 큐레이션 CSV 가져오기 명령(미리보기 → 반영)
5. 조회 API: `GET /api/places`(검색·필터·커서 페이지네이션), `GET /api/places/{id}`, `GET /api/events`
6. 일·주·분기 동기화 + 폐업 대조