diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts index d61bf7d..1a7de31 100644 --- a/backend/src/commands/place-sync.ts +++ b/backend/src/commands/place-sync.ts @@ -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 { - 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 { 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); } diff --git a/backend/src/modules/place/sync/call-budget.ts b/backend/src/modules/place/sync/call-budget.ts index d2cf4b5..38e60dd 100644 --- a/backend/src/modules/place/sync/call-budget.ts +++ b/backend/src/modules/place/sync/call-budget.ts @@ -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'; } diff --git a/backend/src/modules/place/sync/place-sync-status.ts b/backend/src/modules/place/sync/place-sync-status.ts index 975be49..437355e 100644 --- a/backend/src/modules/place/sync/place-sync-status.ts +++ b/backend/src/modules/place/sync/place-sync-status.ts @@ -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; dailyLimit: number; + store: { + byStatus: Record; + byCategory: Record; + 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, private readonly recorder: SyncRunRecorder, - private readonly apiFactory: TourApiFactory, + private readonly apiFactory: PublicDataApiFactory, ) {} async report(regionCode: string): Promise { - 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 { + 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; + } } diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts index 7c42c62..29469a2 100644 --- a/backend/src/modules/place/sync/place-sync.module.ts +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -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 {} diff --git a/backend/src/modules/place/sync/public-data.client.ts b/backend/src/modules/place/sync/public-data.client.ts new file mode 100644 index 0000000..913da1b --- /dev/null +++ b/backend/src/modules/place/sync/public-data.client.ts @@ -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|\s*22\s*; + +export class PublicDataApiError extends Error { + constructor(message: string) { + super(message); + this.name = 'PublicDataApiError'; + } +} + +interface FetchResponse { + ok: boolean; + status: number; + text(): Promise; +} +export type FetchLike = ( + url: string, + init: { signal: AbortSignal }, +) => Promise; + +export interface PublicDataClientOptions { + serviceKey: string; + budget: CallBudget; + baseUrl?: string; + fetchImpl?: FetchLike; + sleep?: (ms: number) => Promise; +} + +const defaultSleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : 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 = /([^<]+) { + private readonly fetchImpl: FetchLike; + private readonly sleep: (ms: number) => Promise; + private lastCallAt = 0; + + protected constructor( + private readonly options: PublicDataClientOptions, + private readonly baseUrl: string, + private readonly commonParams: Record, + ) { + 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, + ): Promise { + 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 { + const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now(); + if (wait > 0) await this.sleep(wait); + this.lastCallAt = Date.now(); + } + + /** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */ + private mask(error: E): E { + const key = this.options.serviceKey; + for (const variant of [key, encodeURIComponent(key)]) { + if (variant) error.message = error.message.split(variant).join('***'); + } + return error; + } +} diff --git a/backend/src/modules/place/sync/public-data.factory.ts b/backend/src/modules/place/sync/public-data.factory.ts new file mode 100644 index 0000000..a588626 --- /dev/null +++ b/backend/src/modules/place/sync/public-data.factory.ts @@ -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('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('PUBLIC_DATA_SERVICE_KEY')?.trim(); + if (!key || /change[-_]?me/i.test(key)) { + // 키 값은 어떤 경우에도 출력하지 않는다 + throw new Error( + 'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요', + ); + } + return key; + } +} diff --git a/backend/src/modules/place/sync/store-api.client.spec.ts b/backend/src/modules/place/sync/store-api.client.spec.ts new file mode 100644 index 0000000..d48550e --- /dev/null +++ b/backend/src/modules/place/sync/store-api.client.spec.ts @@ -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, + ); + }); +}); diff --git a/backend/src/modules/place/sync/store-api.client.ts b/backend/src/modules/place/sync/store-api.client.ts new file mode 100644 index 0000000..c6a369a --- /dev/null +++ b/backend/src/modules/place/sync/store-api.client.ts @@ -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, + ): Promise; +} + +/** + * 상가정보 응답 → 목록. 관광공사와 달리 `{ 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 + implements StoreApi +{ + constructor(options: PublicDataClientOptions) { + super(options, STORE_API_BASE_URL, { type: 'json' }); + } + + protected parse(body: string, operation: string): StorePage { + return parseStorePage(body, operation); + } +} diff --git a/backend/src/modules/place/sync/store-mapper.spec.ts b/backend/src/modules/place/sync/store-mapper.spec.ts new file mode 100644 index 0000000..b4a46f4 --- /dev/null +++ b/backend/src/modules/place/sync/store-mapper.spec.ts @@ -0,0 +1,60 @@ +import { storeItemToPlace } from './store-mapper'; + +const item = (over: Record = {}) => ({ + 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', + }); + }); +}); diff --git a/backend/src/modules/place/sync/store-mapper.ts b/backend/src/modules/place/sync/store-mapper.ts new file mode 100644 index 0000000..8939a2e --- /dev/null +++ b/backend/src/modules/place/sync/store-mapper.ts @@ -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 = { + 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, + }; +} diff --git a/backend/src/modules/place/sync/store.sync.ts b/backend/src/modules/place/sync/store.sync.ts new file mode 100644 index 0000000..f80b1bc --- /dev/null +++ b/backend/src/modules/place/sync/store.sync.ts @@ -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>; + snapshot: string | null; + groups: StoreGroupResult[]; + stoppedByBudget: boolean; + calls: Record; + errors: string[]; +} + +/** + * 소상공인 상가정보 동기화(분기마다). 시군구 × 데이트 업종 소분류로 storeListInDong 을 받아 place 에 upsert 한다. + * 다음 분기에 사라진 업소는 gone(폐업 추정)으로 숨긴다 — 지우지 않는다. + */ +@Injectable() +export class StoreSync { + private readonly logger = new Logger(StoreSync.name); + + constructor( + @InjectRepository(Place) private readonly places: Repository, + private readonly recorder: SyncRunRecorder, + private readonly apiFactory: PublicDataApiFactory, + ) {} + + async run(options: { signguCodes: string[] }): Promise { + 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>(); + const rowsPerGroup = new Map(); + + 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[]): Promise { + 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, + runStart: Date, + ): Promise { + 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); + } +} diff --git a/backend/src/modules/place/sync/tour-api.client.spec.ts b/backend/src/modules/place/sync/tour-api.client.spec.ts index 5628088..3c84894 100644 --- a/backend/src/modules/place/sync/tour-api.client.spec.ts +++ b/backend/src/modules/place/sync/tour-api.client.spec.ts @@ -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)); diff --git a/backend/src/modules/place/sync/tour-api.client.ts b/backend/src/modules/place/sync/tour-api.client.ts index f2bd0a6..a5e9b8a 100644 --- a/backend/src/modules/place/sync/tour-api.client.ts +++ b/backend/src/modules/place/sync/tour-api.client.ts @@ -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|\s*22\s*; +export type TourItem = PublicDataItem; export interface TourPage { items: TourItem[]; @@ -28,70 +33,25 @@ export interface TourApi { ): Promise; } -export class TourApiError extends Error { - constructor(message: string) { - super(message); - this.name = 'TourApiError'; - } -} - -interface FetchResponse { - ok: boolean; - status: number; - text(): Promise; -} -export type FetchLike = ( - url: string, - init: { signal: AbortSignal }, -) => Promise; - -export interface TourApiClientOptions { - serviceKey: string; - budget: CallBudget; - baseUrl?: string; - fetchImpl?: FetchLike; - sleep?: (ms: number) => Promise; -} - -const defaultSleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** - * 응답 본문을 목록으로 바꾼다. 관광공사는 항목이 1건이면 배열 대신 객체를, 0건이면 빈 문자열을 준다. - * 키가 틀리면 _type=json 이어도 XML 로 답한다. + * 관광공사 응답 → 목록. `{ 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 = /([^<]+) 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; - 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 + implements TourApi +{ + constructor(options: PublicDataClientOptions) { + super(options, TOUR_API_BASE_URL, TOUR_COMMON_PARAMS); } - async get( - operation: string, - params: Record, - ): Promise { - const query = new URLSearchParams({ - serviceKey: this.options.serviceKey, - ...COMMON_PARAMS, - ...Object.fromEntries( - Object.entries(params).map(([k, v]) => [k, String(v)]), - ), - }); - const url = `${this.baseUrl}/${operation}?${query.toString()}`; - - let lastError: unknown; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { - this.options.budget.take(operation); - await this.pace(); - try { - const res = await this.fetchImpl(url, { - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - const body = await res.text(); - if (res.status >= 500) { - lastError = new TourApiError(`${operation}: HTTP ${res.status}`); - } else if (!res.ok) { - if (QUOTA_EXCEEDED_RE.test(body)) { - throw new BudgetExceededError(operation, 'server'); - } - throw new TourApiError(`${operation}: HTTP ${res.status}`); - } else { - return parseTourPage(body, operation); - } - } catch (error) { - if ( - error instanceof BudgetExceededError || - error instanceof TourApiError - ) { - throw this.mask(error); - } - lastError = error; // 네트워크 오류·시간 초과 → 다시 시도 - } - if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt); - } - const reason = - lastError instanceof Error ? lastError.message : String(lastError); - throw this.mask( - new TourApiError(`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`), - ); - } - - private async pace(): Promise { - const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now(); - if (wait > 0) await this.sleep(wait); - this.lastCallAt = Date.now(); - } - - /** 오류 메시지에 키(원문·URL 인코딩)가 섞였으면 가린다 */ - private mask(error: E): E { - const key = this.options.serviceKey; - for (const variant of [key, encodeURIComponent(key)]) { - if (variant) error.message = error.message.split(variant).join('***'); - } - return error; + protected parse(body: string, operation: string): TourPage { + return parseTourPage(body, operation); } } diff --git a/backend/src/modules/place/sync/tour-api.factory.ts b/backend/src/modules/place/sync/tour-api.factory.ts deleted file mode 100644 index 7d9ba92..0000000 --- a/backend/src/modules/place/sync/tour-api.factory.ts +++ /dev/null @@ -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('TOUR_API_DAILY_LIMIT'), - DEFAULT_DAILY_LIMIT, - ); - return Math.max(0, limit - SAFETY_MARGIN); - } - - create(budget: CallBudget): TourApi { - const key = this.config.get('PUBLIC_DATA_SERVICE_KEY')?.trim(); - if (!key || /change[-_]?me/i.test(key)) { - // 키 값은 어떤 경우에도 출력하지 않는다 - throw new Error( - 'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요', - ); - } - return new TourApiClient({ serviceKey: key, budget }); - } -} diff --git a/backend/src/modules/place/sync/tour-kor.sync.ts b/backend/src/modules/place/sync/tour-kor.sync.ts index 1d8fe6b..830b97c 100644 --- a/backend/src/modules/place/sync/tour-kor.sync.ts +++ b/backend/src/modules/place/sync/tour-kor.sync.ts @@ -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, private readonly recorder: SyncRunRecorder, - private readonly apiFactory: TourApiFactory, + private readonly apiFactory: PublicDataApiFactory, ) {} async run(options: TourKorOptions): Promise { 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, diff --git a/backend/src/modules/place/sync/tour-mapper.ts b/backend/src/modules/place/sync/tour-mapper.ts index 9167b9c..2d1b696 100644 --- a/backend/src/modules/place/sync/tour-mapper.ts +++ b/backend/src/modules/place/sync/tour-mapper.ts @@ -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' }; } diff --git a/backend/src/modules/place/sync/tour-pet.sync.ts b/backend/src/modules/place/sync/tour-pet.sync.ts index 45a6f18..808189c 100644 --- a/backend/src/modules/place/sync/tour-pet.sync.ts +++ b/backend/src/modules/place/sync/tour-pet.sync.ts @@ -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, private readonly recorder: SyncRunRecorder, - private readonly apiFactory: TourApiFactory, + private readonly apiFactory: PublicDataApiFactory, ) {} async run(): Promise { 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, diff --git a/backend/src/shared/utils/geo.spec.ts b/backend/src/shared/utils/geo.spec.ts new file mode 100644 index 0000000..788c905 --- /dev/null +++ b/backend/src/shared/utils/geo.spec.ts @@ -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); + }); +}); diff --git a/backend/src/shared/utils/geo.ts b/backend/src/shared/utils/geo.ts new file mode 100644 index 0000000..765c129 --- /dev/null +++ b/backend/src/shared/utils/geo.ts @@ -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 + ); +} diff --git a/docs/design/place-db.md b/docs/design/place-db.md index e39e545..1571612 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -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. 일·주·분기 동기화 + 폐업 대조