diff --git a/backend/.env.development.example b/backend/.env.development.example index 397073f..a0e090b 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -22,6 +22,10 @@ PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key # 운영 계정 승인 후 승인된 한도로 올린다. TOUR_API_DAILY_LIMIT= +# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키). +# kopis.or.kr → Open API 에서 신청하면 이메일로 온다. +KOPIS_SERVICE_KEY=change-me-kopis-service-key + # 카카오 REST API 키 — 장소 검색(로컬 API)을 서버에서만 호출한다. # 응답(이름·주소·좌표)은 저장·캐시·LLM 전송 금지. 장소 ID·place_url 만 저장할 수 있다. KAKAO_REST_API_KEY=change-me-kakao-rest-api-key diff --git a/backend/.env.production.example b/backend/.env.production.example index 4bede34..3c4fd22 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -20,6 +20,10 @@ PUBLIC_DATA_SERVICE_KEY=change-me-data-go-kr-decoding-key # 운영 계정 승인 후 승인된 한도로 올린다. TOUR_API_DAILY_LIMIT= +# 공연예술통합전산망(KOPIS) Open API 인증키 — 공연 정보 동기화(서버에서만 쓴다, 1인 1키). +# kopis.or.kr → Open API 에서 신청하면 이메일로 온다. +KOPIS_SERVICE_KEY=change-me-kopis-service-key + # 카카오 REST API 키 — 운영에 쓸 카카오 앱의 키. 응답 저장·캐시·LLM 전송 금지. KAKAO_REST_API_KEY=change-me-kakao-rest-api-key diff --git a/backend/package-lock.json b/backend/package-lock.json index f62dd97..b76cb26 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,7 @@ "cache-manager": "^6.4.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "fast-xml-parser": "^5.11.2", "helmet": "^8.1.0", "nest-winston": "^1.10.0", "pg": "^8.20.0", @@ -3343,6 +3344,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -5340,6 +5353,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/app-root-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", @@ -7415,6 +7440,45 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8536,6 +8600,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz", + "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -10535,6 +10611,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -11829,6 +11920,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -13491,6 +13597,21 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index ccda9b5..7762ab4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,6 +43,7 @@ "cache-manager": "^6.4.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "fast-xml-parser": "^5.11.2", "helmet": "^8.1.0", "nest-winston": "^1.10.0", "pg": "^8.20.0", diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts index 3f4233e..f7a2a87 100644 --- a/backend/src/commands/place-sync.ts +++ b/backend/src/commands/place-sync.ts @@ -8,6 +8,7 @@ import { DEFAULT_EVENT_REGIONS, FestivalSync, } from '@/modules/place/sync/festival.sync'; +import { KopisSync } from '@/modules/place/sync/kopis.sync'; import { PlaceDedupe } from '@/modules/place/sync/place-dedupe'; import { PlaceHoursReparse } from '@/modules/place/sync/place-hours-reparse'; import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status'; @@ -25,7 +26,7 @@ import { PlaceSyncCliModule } from './place-sync.cli.module'; /** * 장소 동기화 명령 (설계: docs/design/place-db.md §4). - * 공공데이터 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다. + * 공공데이터·KOPIS 키를 쓰므로 키가 있는 환경(개발 컨테이너·운영 서버)에서 실행한다. * * 개발: docker compose ... exec backend npm run place:sync -- <작업> [옵션] * 운영: node dist/commands/place-sync.js <작업> [옵션] (cron) @@ -40,6 +41,9 @@ const USAGE = `사용법: place:sync <작업> [옵션] event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다 --regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북) --days=90 며칠 뒤까지 받을지 + kopis KOPIS 공연 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 공연만 상세를, 새 공연장만 좌표를 받는다 + --regions=27,47 시·도 코드들 (기본 27 대구 · 47 경북) + --days=90 며칠 뒤까지 받을지 dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행) reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번) status 지역별 적재 현황과 오늘 쓴 호출 수 @@ -50,6 +54,7 @@ const JOBS = [ 'tour-pet', 'store', 'event', + 'kopis', 'dedupe', 'reparse-hours', 'status', @@ -133,6 +138,8 @@ async function main(): Promise { await app.get(StoreSync).run({ signguCodes: signgu }); } else if (job === 'event') { await app.get(FestivalSync).runRegions({ regionCodes: regions, days }); + } else if (job === 'kopis') { + await app.get(KopisSync).runRegions({ regionCodes: regions, days }); } else if (job === 'dedupe') { await app.get(PlaceDedupe).run(); } else if (job === 'reparse-hours') { diff --git a/backend/src/modules/place/sync/kopis-api.client.spec.ts b/backend/src/modules/place/sync/kopis-api.client.spec.ts new file mode 100644 index 0000000..06b561e --- /dev/null +++ b/backend/src/modules/place/sync/kopis-api.client.spec.ts @@ -0,0 +1,135 @@ +import { CallBudget } from './call-budget'; +import { KopisApiClient, parseKopisBody } from './kopis-api.client'; +import { FetchLike, PublicDataApiError } from './public-data.client'; + +const KEY = 'kopis+key/123'; + +const LIST_XML = ` + + PF100001대구시립교향악단 정기연주회 & 협연2026.12.04 + PF100002장화신은 고양이 +`; + +const DETAIL_XML = ` + PF100001FC001193053-250-1400 + https://kopis.or.kr/a.jpg + 티켓링크https://www.ticketlink.co.kr/1 +`; + +function client(responses: string[], budget = new CallBudget(100)) { + const urls: string[] = []; + const fetchImpl: FetchLike = (url) => { + urls.push(url); + const body = responses.shift(); + if (body === undefined) throw new Error('예상보다 많이 호출했다'); + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(body), + }); + }; + const api = new KopisApiClient({ + serviceKey: KEY, + budget, + fetchImpl, + sleep: () => Promise.resolve(), + }); + return { api, urls, budget }; +} + +describe('parseKopisBody', () => { + it(' 여러 건을 항목 배열로 바꾸고 엔티티를 되돌린다', () => { + const items = parseKopisBody(LIST_XML, 'pblprfr'); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + mt20id: 'PF100001', + prfnm: '대구시립교향악단 정기연주회 & 협연', + prfpdfrom: '2026.12.04', + }); + }); + + it('1건이어도 배열이고, 숫자처럼 보이는 값도 문자열로 둔다', () => { + const [db] = parseKopisBody(DETAIL_XML, 'pblprfr-detail'); + expect(db.telno).toBe('053-250-1400'); + expect(db.styurls).toEqual({ styurl: ['https://kopis.or.kr/a.jpg'] }); + expect(db.relates).toEqual({ + relate: [ + { relatenm: '티켓링크', relateurl: 'https://www.ticketlink.co.kr/1' }, + ], + }); + }); + + it('0건()이면 빈 배열', () => { + expect(parseKopisBody('', 'pblprfr')).toEqual([]); + expect(parseKopisBody('', 'pblprfr')).toEqual([]); + }); + + it('returncode·errmsg 가 오면 사유를 담은 오류로 본다', () => { + const xml = + '02SERVICE KEY IS NOT REGISTERED ERROR.'; + expect(() => parseKopisBody(xml, 'pblprfr')).toThrow( + /pblprfr: 결과 코드 02 SERVICE KEY IS NOT REGISTERED ERROR\./, + ); + }); + + it('XML 이 아니거나 구조가 다르면 오류', () => { + expect(() => + parseKopisBody('점검 중', 'x'), + ).toThrow(PublicDataApiError); + expect(() => parseKopisBody('', 'x')).toThrow(PublicDataApiError); + }); +}); + +describe('KopisApiClient', () => { + it('키는 service 파라미터로 싣고 목록 조건을 그대로 넘긴다', async () => { + const { api, urls, budget } = client([LIST_XML]); + const items = await api.listPerformances({ + stdate: '20261103', + eddate: '20270201', + signgucode: '27', + cpage: 1, + rows: 100, + }); + expect(items).toHaveLength(2); + const url = new URL(urls[0]); + expect(url.origin + url.pathname).toBe( + 'https://kopis.or.kr/openApi/restful/pblprfr', + ); + expect(url.searchParams.get('service')).toBe(KEY); + expect(url.searchParams.has('serviceKey')).toBe(false); + expect(url.searchParams.get('signgucode')).toBe('27'); + expect(url.searchParams.has('afterdate')).toBe(false); + expect(budget.spent()).toEqual({ pblprfr: 1 }); + }); + + it('상세는 경로에 id 를 넣고 한도는 오퍼레이션 이름으로 센다', async () => { + const { api, urls, budget } = client([DETAIL_XML, '']); + await expect(api.getPerformance('PF100001')).resolves.toMatchObject({ + mt10id: 'FC001193', + }); + await expect(api.getVenue('FC001193')).resolves.toBeNull(); + expect(new URL(urls[0]).pathname).toMatch(/\/pblprfr\/PF100001$/); + expect(new URL(urls[1]).pathname).toMatch(/\/prfplc\/FC001193$/); + expect(budget.spent()).toEqual({ + 'pblprfr-detail': 1, + 'prfplc-detail': 1, + }); + }); + + it('영문·숫자가 아닌 id 는 부르지 않는다(경로 조작 방지)', async () => { + const { api, urls } = client([]); + await expect(api.getPerformance('../prfplc/FC1')).rejects.toThrow( + PublicDataApiError, + ); + expect(urls).toHaveLength(0); + }); + + it('오류 메시지에 키가 섞여도 가린다', async () => { + const { api } = client([ + `02bad key ${KEY}`, + ]); + const error = await api.getVenue('FC1').catch((e: Error) => e); + expect(error).toBeInstanceOf(PublicDataApiError); + expect((error as Error).message).not.toContain(KEY); + }); +}); diff --git a/backend/src/modules/place/sync/kopis-api.client.ts b/backend/src/modules/place/sync/kopis-api.client.ts new file mode 100644 index 0000000..c071522 --- /dev/null +++ b/backend/src/modules/place/sync/kopis-api.client.ts @@ -0,0 +1,135 @@ +import { XMLParser } from 'fast-xml-parser'; + +import { + PublicDataApiError, + PublicDataClient, + PublicDataClientOptions, + PublicDataItem, + asRecord, + toItems, +} from './public-data.client'; + +/** + * KOPIS 공연예술통합전산망 Open API (XML). + * 2026-11-03 진단: https 동작, 90일 기간을 한 번에 조회 가능, 초당 10회에서 차단되므로 0.6초 간격으로 부른다. + * 출처 표기 의무: "(재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)". + */ +export const KOPIS_API_BASE_URL = 'https://kopis.or.kr/openApi/restful'; +const KOPIS_CALL_INTERVAL_MS = 600; + +/** 호출 종류 — 한도·실행 기록의 오퍼레이션 이름 */ +export const KOPIS_OPERATIONS = { + list: 'pblprfr', + detail: 'pblprfr-detail', + venue: 'prfplc-detail', +} as const; + +export type KopisItem = PublicDataItem; + +/** 같은 이름이 여러 번 나와도 하나만 나와도 배열로 받는 태그 */ +const ARRAY_TAGS = new Set(['db', 'styurl', 'relate']); + +const parser = new XMLParser({ + ignoreAttributes: true, + // 숫자처럼 보이는 값(공연 id·전화번호·좌표)도 문자열 그대로 둔다 + parseTagValue: false, + trimValues: true, + isArray: (tagName) => ARRAY_TAGS.has(tagName), +}); + +/** + * KOPIS 응답 → `` 항목들. 목록·상세·공연시설 모두 `…` 구조다. + * 키 오류 등은 `……` 로 온다. 0건이면 ``. + */ +export function parseKopisBody(body: string, operation: string): KopisItem[] { + let doc: unknown; + try { + doc = parser.parse(body); + } catch { + throw new PublicDataApiError(`${operation}: XML 이 아닌 응답`); + } + const root = asRecord(doc); + if (!root || !('dbs' in root)) { + throw new PublicDataApiError(`${operation}: 알 수 없는 응답 형식`); + } + const items = toItems(asRecord(root.dbs)?.db); + const first = items[0]; + if (first && ('returncode' in first || 'errmsg' in first)) { + const code = typeof first.returncode === 'string' ? first.returncode : ''; + const msg = typeof first.errmsg === 'string' ? first.errmsg : ''; + throw new PublicDataApiError( + `${operation}: 결과 코드 ${code} ${msg}`.trim(), + ); + } + return items; +} + +/** 동기화가 쓰는 KOPIS 호출 창구 — 테스트에서는 가짜로 바꿔 끼운다 */ +export interface KopisApi { + /** 공연목록 한 쪽 — 기간이 겹치는 공연(stdate·eddate 'YYYYMMDD') */ + listPerformances(params: { + stdate: string; + eddate: string; + signgucode: string; + cpage: number; + rows: number; + afterdate?: string; + }): Promise; + /** 공연상세(mt20id) — 없으면 null */ + getPerformance(mt20id: string): Promise; + /** 공연시설상세(mt10id) — 없으면 null */ + getVenue(mt10id: string): Promise; +} + +/** 경로에 넣는 id — 영문·숫자만 허용한다(경로 조작 방지) */ +function safeId(id: string, operation: string): string { + if (!/^[A-Za-z0-9]+$/.test(id)) { + throw new PublicDataApiError(`${operation}: 형식이 잘못된 id`); + } + return id; +} + +export class KopisApiClient + extends PublicDataClient + implements KopisApi +{ + constructor(options: PublicDataClientOptions) { + super( + options, + KOPIS_API_BASE_URL, + {}, + { keyParam: 'service', intervalMs: KOPIS_CALL_INTERVAL_MS }, + ); + } + + protected parse(body: string, operation: string): KopisItem[] { + return parseKopisBody(body, operation); + } + + listPerformances(params: { + stdate: string; + eddate: string; + signgucode: string; + cpage: number; + rows: number; + afterdate?: string; + }): Promise { + const { afterdate, ...rest } = params; + return this.get(KOPIS_OPERATIONS.list, { + ...rest, + ...(afterdate ? { afterdate } : {}), + }); + } + + async getPerformance(mt20id: string): Promise { + const op = KOPIS_OPERATIONS.detail; + const items = await this.get(op, {}, `pblprfr/${safeId(mt20id, op)}`); + return items[0] ?? null; + } + + async getVenue(mt10id: string): Promise { + const op = KOPIS_OPERATIONS.venue; + const items = await this.get(op, {}, `prfplc/${safeId(mt10id, op)}`); + return items[0] ?? null; + } +} diff --git a/backend/src/modules/place/sync/kopis-mapper.spec.ts b/backend/src/modules/place/sync/kopis-mapper.spec.ts new file mode 100644 index 0000000..3b5b6e7 --- /dev/null +++ b/backend/src/modules/place/sync/kopis-mapper.spec.ts @@ -0,0 +1,207 @@ +import { + kopisDate, + kopisDetail, + kopisImageUrl, + kopisListItemToEvent, + kopisTimestamp, + kopisVenue, +} from './kopis-mapper'; + +describe('kopisDate', () => { + it("'YYYY.MM.DD' 를 ISO 날짜로 바꾼다", () => { + expect(kopisDate('2026.12.04')).toBe('2026-12-04'); + }); + + it('형식이 다르거나 없는 날짜면 null', () => { + expect(kopisDate('20261204')).toBeNull(); + expect(kopisDate('2026.02.30')).toBeNull(); + expect(kopisDate(null)).toBeNull(); + }); +}); + +describe('kopisTimestamp', () => { + it('한국 시간 수정일(마이크로초 포함)을 UTC 로 바꾼다', () => { + expect(kopisTimestamp('2026-11-03 15:00:03.094823')?.toISOString()).toBe( + '2026-11-03T06:00:03.000Z', + ); + }); + + it('형식이 다르면 null', () => { + expect(kopisTimestamp('2026.11.03')).toBeNull(); + }); +}); + +describe('kopisImageUrl', () => { + it('KOPIS http 주소는 https://kopis.or.kr 로 바꾼다', () => { + expect( + kopisImageUrl('http://www.kopis.or.kr/upload/pfmPoster/PF_1.gif'), + ).toBe('https://kopis.or.kr/upload/pfmPoster/PF_1.gif'); + expect(kopisImageUrl('/upload/pfmPoster/PF_2.jpg')).toBe( + 'https://kopis.or.kr/upload/pfmPoster/PF_2.jpg', + ); + }); + + it('다른 곳의 https 는 그대로, http 는 버린다', () => { + expect(kopisImageUrl('https://cdn.example.org/a.jpg')).toBe( + 'https://cdn.example.org/a.jpg', + ); + expect(kopisImageUrl('http://cdn.example.org/a.jpg')).toBeNull(); + expect(kopisImageUrl(null)).toBeNull(); + }); +}); + +describe('kopisListItemToEvent', () => { + const item = { + mt20id: 'PF281234', + prfnm: '제531회 대구시립교향악단 정기연주회', + prfpdfrom: '2026.12.04', + prfpdto: '2026.12.04', + fcltynm: '대구콘서트하우스', + poster: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF281234.gif', + area: '대구광역시', + genrenm: '서양음악(클래식)', + prfstate: '공연예정', + }; + + it('목록 항목을 기본 컬럼으로 바꾼다', () => { + expect(kopisListItemToEvent(item)).toEqual({ + sourceId: 'PF281234', + name: '제531회 대구시립교향악단 정기연주회', + startDate: '2026-12-04', + endDate: '2026-12-04', + genre: '서양음악(클래식)', + eventPlace: '대구콘서트하우스', + area: '대구광역시', + imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif', + imageThumbUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif', + }); + }); + + it('id·이름이 없거나 id 형식이 이상하면 건너뛴다', () => { + expect(kopisListItemToEvent({ ...item, mt20id: '' })).toEqual({ + skip: 'no_id', + }); + expect(kopisListItemToEvent({ ...item, mt20id: 'PF/1' })).toEqual({ + skip: 'no_id', + }); + }); + + it('기간이 틀렸거나 끝난 공연은 건너뛴다', () => { + expect(kopisListItemToEvent({ ...item, prfpdto: '2026.12.01' })).toEqual({ + skip: 'bad_dates', + }); + expect(kopisListItemToEvent({ ...item, prfstate: '공연완료' })).toEqual({ + skip: 'ended', + }); + }); +}); + +describe('kopisDetail', () => { + const db = { + mt20id: 'PF281234', + mt10id: 'FC001193', + prfnm: '제531회 대구시립교향악단 정기연주회', + prfpdfrom: '2026.12.04', + prfpdto: '2026.12.04', + fcltynm: '대구콘서트하우스 (그랜드홀 (대공연장))', + prfcast: '백진현, 김민지', + prfruntime: '1시간 40분', + prfage: '만 7세 이상', + pcseguidance: 'R석 30,000원, S석 16,000원', + dtguidance: '금요일(19:30)', + sty: '

송년 음악회

', + genrenm: '서양음악(클래식)', + poster: 'http://www.kopis.or.kr/upload/pfmPoster/PF_PF281234.gif', + updatedate: '2026-11-03 15:00:03.094823', + relates: { + relate: [ + { relatenm: '전화 예매', relateurl: '053-250-1400' }, + { relatenm: '티켓링크', relateurl: 'https://www.ticketlink.co.kr/1' }, + ], + }, + }; + + it('요금·공연 시간·예매처·시설 id 를 꺼내고 소개에 출연·관람 정보를 붙인다', () => { + expect(kopisDetail(db)).toEqual({ + name: '제531회 대구시립교향악단 정기연주회', + startDate: '2026-12-04', + endDate: '2026-12-04', + genre: '서양음악(클래식)', + eventPlace: '대구콘서트하우스 (그랜드홀 (대공연장))', + feeText: 'R석 30,000원, S석 16,000원', + playtimeText: '금요일(19:30)', + overview: + '송년 음악회\n출연: 백진현, 김민지\n관람 시간: 1시간 40분\n관람 연령: 만 7세 이상', + bookingUrl: 'https://www.ticketlink.co.kr/1', + imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PF281234.gif', + sourceModifiedAt: new Date('2026-11-03T06:00:03.000Z'), + venueSourceId: 'FC001193', + }); + }); + + it('비어 있는 칸은 null 이고, 기간이 틀리면 기간을 쓰지 않는다', () => { + const d = kopisDetail({ + mt20id: 'PF1', + prfpdfrom: '2026.12.04', + prfpdto: '2026.12.01', + relates: '', + }); + expect(d).toMatchObject({ + startDate: null, + endDate: null, + overview: null, + bookingUrl: null, + venueSourceId: null, + }); + }); +}); + +describe('kopisVenue', () => { + it('주소의 시·도 안 좌표는 그대로 쓴다', () => { + expect( + kopisVenue( + { + fcltynm: '대구콘서트하우스', + adres: '대구광역시 중구 태평로 141 (태평로2가)', + la: '35.876047100000000', + lo: '128.59384449999993', + telno: '053-250-1400', + relateurl: 'http://www.daeguconcerthouse.or.kr', + }, + '대구콘서트하우스 (그랜드홀)', + ), + ).toEqual({ + name: '대구콘서트하우스', + address: '대구광역시 중구 태평로 141 (태평로2가)', + lat: 35.8760471, + lng: 128.59384449999993, + phone: '053-250-1400', + homepage: 'http://www.daeguconcerthouse.or.kr', + coordsRejected: false, + }); + }); + + it('대구 주소에 경주 좌표면 좌표를 비운다(2026-11-03 실제 사례)', () => { + const v = kopisVenue( + { + fcltynm: '프로토타운 북성로 본부', + adres: '대구광역시 중구 북성로 96 (북성로1가)', + la: '35.847189000000000', + lo: '129.21230600000000', + }, + '프로토타운', + ); + expect(v).toMatchObject({ lat: null, lng: null, coordsRejected: true }); + }); + + it('좌표가 없으면 비우되 거부로 세지 않고, 이름이 없으면 대체 이름을 쓴다', () => { + expect( + kopisVenue({ adres: '대구광역시 남구', la: '', lo: '' }, '꿈꾸는씨어터'), + ).toMatchObject({ + name: '꿈꾸는씨어터', + lat: null, + lng: null, + coordsRejected: false, + }); + }); +}); diff --git a/backend/src/modules/place/sync/kopis-mapper.ts b/backend/src/modules/place/sync/kopis-mapper.ts new file mode 100644 index 0000000..862c43f --- /dev/null +++ b/backend/src/modules/place/sync/kopis-mapper.ts @@ -0,0 +1,190 @@ +import { isInKorea, isInSido, sidoCodeFromAddress } from '@/shared/utils/geo'; +import { extractUrl, htmlToText } from '@/shared/utils/html-text'; +import { parseKstTimestamp } from '@/shared/utils/kst'; +import { toIsoDate } from './festival-mapper'; +import type { KopisItem } from './kopis-api.client'; +import { asRecord, toItems } from './public-data.client'; + +/** + * KOPIS 공연목록·공연상세·공연시설상세 → festival_event / event_venue 컬럼 (순수 함수). + * 칸 이름은 2026-11-03 진단(tools/data-check/culture_api_check.py) 기준이다. + */ + +const KOPIS_HOST_RE = /^https?:\/\/(www\.)?kopis\.or\.kr\//i; +const KOPIS_HTTPS_ORIGIN = 'https://kopis.or.kr/'; +/** KOPIS id(공연 PF…, 시설 FC…) — 상세 주소 경로에 들어가므로 영문·숫자만 받는다 */ +const KOPIS_ID_RE = /^[A-Za-z0-9]+$/; +/** 끝난 공연 — 기간이 겹쳐 목록에 와도 넣지 않는다 */ +const ENDED_STATE = '공연완료'; + +export interface KopisListEvent { + sourceId: string; + name: string; + startDate: string; + endDate: string; + genre: string | null; + eventPlace: string | null; + /** 목록 area('대구광역시') — 지역 확인용 */ + area: string | null; + imageUrl: string | null; + imageThumbUrl: string | null; +} + +export type KopisSkipReason = 'no_id' | 'bad_dates' | 'ended'; + +function str(item: KopisItem, 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; +} + +/** 'YYYY.MM.DD' → 'YYYY-MM-DD'. 형식이 틀리거나 없는 날짜면 null */ +export function kopisDate(value: string | null): string | null { + const m = value ? /^(\d{4})\.(\d{2})\.(\d{2})$/.exec(value) : null; + return m ? toIsoDate(`${m[1]}${m[2]}${m[3]}`) : null; +} + +/** 상세 updatedate('2026-11-03 15:00:03.094823', 한국 시간) → Date */ +export function kopisTimestamp(value: string | null): Date | null { + const m = value + ? /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(value) + : null; + return m ? parseKstTimestamp(m.slice(1).join('')) : null; +} + +/** + * 포스터 주소 — KOPIS 는 http://www.kopis.or.kr/upload/… 로 준다. + * 앱은 http 이미지를 막으므로 https://kopis.or.kr/ 로 바꾼다(www 는 https 에서 이 주소로 넘긴다). + */ +export function kopisImageUrl(value: string | null): string | null { + if (!value) return null; + if (KOPIS_HOST_RE.test(value)) { + return value.replace(KOPIS_HOST_RE, KOPIS_HTTPS_ORIGIN); + } + if (value.startsWith('/upload/')) + return `${KOPIS_HTTPS_ORIGIN}${value.slice(1)}`; + return /^https:\/\//i.test(value) ? value : null; +} + +/** 목록 항목 1건 → 기본 컬럼. 쓸 수 없으면 사유를 돌려준다 */ +export function kopisListItemToEvent( + item: KopisItem, +): KopisListEvent | { skip: KopisSkipReason } { + const sourceId = str(item, 'mt20id'); + const name = htmlToText(item.prfnm); + if (!sourceId || !KOPIS_ID_RE.test(sourceId) || !name) { + return { skip: 'no_id' }; + } + const startDate = kopisDate(str(item, 'prfpdfrom')); + const endDate = kopisDate(str(item, 'prfpdto')); + if (!startDate || !endDate || endDate < startDate) { + return { skip: 'bad_dates' }; + } + if (str(item, 'prfstate') === ENDED_STATE) return { skip: 'ended' }; + const poster = kopisImageUrl(str(item, 'poster')); + return { + sourceId, + name, + startDate, + endDate, + genre: str(item, 'genrenm'), + eventPlace: htmlToText(item.fcltynm), + area: str(item, 'area'), + imageUrl: poster, + imageThumbUrl: poster, + }; +} + +export interface KopisDetail { + name: string | null; + startDate: string | null; + endDate: string | null; + genre: string | null; + eventPlace: string | null; + feeText: string | null; + playtimeText: string | null; + overview: string | null; + bookingUrl: string | null; + imageUrl: string | null; + sourceModifiedAt: Date | null; + /** 공연시설 id(mt10id) — 좌표는 시설 상세에만 있다 */ + venueSourceId: string | null; +} + +/** '출연: …' 처럼 이름을 붙인 줄. 값이 없으면 null */ +function labeled(label: string, value: unknown): string | null { + const text = htmlToText(value); + return text ? `${label}: ${text}` : null; +} + +/** + * 공연상세 → 상세 컬럼. + * 소개는 줄거리(sty)에 출연·관람 시간·관람 연령을 줄로 붙인다(따로 둘 컬럼이 없다). + * 예매처(relates)는 여러 곳일 수 있어 첫 번째 http(s) 링크를 쓴다. + */ +export function kopisDetail(db: KopisItem): KopisDetail { + const overview = [ + htmlToText(db.sty), + labeled('출연', db.prfcast), + labeled('관람 시간', db.prfruntime), + labeled('관람 연령', db.prfage), + ] + .filter((line): line is string => line !== null) + .join('\n'); + const relates = toItems(asRecord(db.relates)?.relate); + const bookingUrl = + relates.map((r) => extractUrl(r.relateurl)).find((u) => u !== null) ?? null; + const venueId = str(db, 'mt10id'); + const startDate = kopisDate(str(db, 'prfpdfrom')); + const endDate = kopisDate(str(db, 'prfpdto')); + const validPeriod = + startDate !== null && endDate !== null && endDate >= startDate; + return { + name: htmlToText(db.prfnm), + startDate: validPeriod ? startDate : null, + endDate: validPeriod ? endDate : null, + genre: str(db, 'genrenm'), + eventPlace: htmlToText(db.fcltynm), + feeText: htmlToText(db.pcseguidance), + playtimeText: htmlToText(db.dtguidance), + overview: overview === '' ? null : overview, + bookingUrl, + imageUrl: kopisImageUrl(str(db, 'poster')), + sourceModifiedAt: kopisTimestamp(str(db, 'updatedate')), + venueSourceId: venueId && KOPIS_ID_RE.test(venueId) ? venueId : null, + }; +} + +export interface KopisVenue { + name: string; + address: string | null; + lat: number | null; + lng: number | null; + phone: string | null; + homepage: string | null; + /** 좌표가 있었지만 주소의 시·도와 맞지 않아 비웠다 */ + coordsRejected: boolean; +} + +/** + * 공연시설상세 → event_venue 컬럼. + * 좌표(la·lo)는 주소의 시·도 영역 안일 때만 쓴다 — 다른 지역 좌표가 섞여 있었다(2026-11-03). + */ +export function kopisVenue(db: KopisItem, fallbackName: string): KopisVenue { + const address = htmlToText(db.adres); + const lat = Number(str(db, 'la')); + const lng = Number(str(db, 'lo')); + const hasCoords = isInKorea(lat, lng); + const sido = sidoCodeFromAddress(address); + const ok = hasCoords && (sido === null || isInSido(sido, lat, lng)); + return { + name: htmlToText(db.fcltynm) ?? fallbackName, + address, + lat: ok ? lat : null, + lng: ok ? lng : null, + phone: htmlToText(db.telno), + homepage: extractUrl(db.relateurl), + coordsRejected: hasCoords && !ok, + }; +} diff --git a/backend/src/modules/place/sync/kopis.sync.ts b/backend/src/modules/place/sync/kopis.sync.ts new file mode 100644 index 0000000..4e18c94 --- /dev/null +++ b/backend/src/modules/place/sync/kopis.sync.ts @@ -0,0 +1,585 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { sidoCodeFromAddress } from '@/shared/utils/geo'; +import { kstDateString } from '@/shared/utils/kst'; +import { EventVenue } from '../entities/event-venue.entity'; +import { FestivalEvent } from '../entities/festival-event.entity'; +import { BudgetExceededError, CallBudget } from './call-budget'; +import { DEFAULT_EVENT_LOOKAHEAD_DAYS } from './festival.sync'; +import { KOPIS_OPERATIONS } from './kopis-api.client'; +import type { KopisApi, KopisItem } from './kopis-api.client'; +import { + KopisSkipReason, + kopisDetail, + kopisListItemToEvent, + kopisVenue, +} from './kopis-mapper'; +import { PublicDataApiFactory } from './public-data.factory'; +import { isSuspiciousRemoval } from './removal-guard'; +import { SyncRunRecorder } from './sync-run.recorder'; + +const SOURCE = 'kopis'; +const PAGE_SIZE = 100; +const MAX_PAGES = 30; +const UPSERT_CHUNK = 200; +const MAX_CONSECUTIVE_FAILURES = 10; +/** 목록에 바뀐 표시가 없어도 이 기간이 지나면 상세를 다시 받는다(동기화를 며칠 건너뛴 경우 대비) */ +const DETAIL_REFRESH_DAYS = 7; +/** 공연시설 상세(주소·좌표)를 다시 받는 주기 */ +const VENUE_REFRESH_DAYS = 30; +const DAY_MS = 86_400_000; + +/** + * 목록 upsert 때 덮어쓰는 컬럼. 장소 이름(eventPlace)은 상세가 홀 이름까지 주므로 처음 넣을 때만 쓴다. + * 좌표·주소·전화는 공연시설에서 온다(applyVenueLocations). + */ +const LIST_OVERWRITE_PROPS = [ + 'kind', + 'name', + 'startDate', + 'endDate', + 'genre', + 'imageUrl', + 'imageThumbUrl', + 'ldongRegnCd', + 'status', + 'lastSeenAt', + 'updatedAt', +] as const satisfies readonly (keyof FestivalEvent)[]; + +type SkipReason = KopisSkipReason | 'other_region' | 'duplicate'; + +export interface KopisSyncResult { + regionCode: string; + window: { from: string; to: string }; + fetched: number; + inserted: number; + updated: number; + hidden: number; + skipped: Partial>; + /** 어제 이후 KOPIS 에서 등록·수정된 공연 수(afterdate) */ + changed: number; + detailed: number; + detailFailed: number; + detailRemaining: number; + venuesSynced: number; + venueFailed: number; + /** 좌표가 주소의 시·도와 맞지 않아 비운 공연시설 */ + venueCoordsRejected: string[]; + stoppedByBudget: boolean; + calls: Record; + errors: string[]; +} + +const toYmd = (iso: string) => iso.replace(/-/g, ''); + +const errorText = (error: unknown) => + error instanceof Error ? error.message : String(error); + +/** + * KOPIS 공연 동기화(매일). 오늘부터 N일 안에 열리는 시·도 공연을 받아 festival_event(source='kopis')에 upsert 한다. + * 1. 목록 — 기간이 겹치는 공연. 목록에 없어진 공연은 숨긴다(원본 장애 의심이면 건너뜀) + * 2. 상세 — 새 공연 · 어제 이후 바뀐 공연(afterdate) · 7일 지난 공연만. 요금·공연 시간·예매처·공연시설 id + * 3. 공연시설 — 새 시설과 30일 지난 시설만. 주소·좌표(좌표는 공연 목록·상세에 없다) + * 4. 공연시설의 좌표·주소·전화를 공연에 복사 — 조회 API 가 공연 좌표로 거리를 계산한다 + * 출처 표기는 조회 API 가 붙인다(festival.service PROVIDER_LABEL). + */ +@Injectable() +export class KopisSync { + private readonly logger = new Logger(KopisSync.name); + + constructor( + @InjectRepository(FestivalEvent) + private readonly events: Repository, + @InjectRepository(EventVenue) + private readonly venues: Repository, + private readonly recorder: SyncRunRecorder, + private readonly apiFactory: PublicDataApiFactory, + ) {} + + /** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */ + async runRegions(options: { + regionCodes: string[]; + days?: number; + }): Promise { + const results: KopisSyncResult[] = []; + for (const regionCode of options.regionCodes) { + const result = await this.run({ regionCode, days: options.days }); + results.push(result); + if (result.stoppedByBudget) break; // 한도는 시·도가 함께 쓴다 + } + return results; + } + + async run(options: { + regionCode: string; + days?: number; + }): Promise { + const { regionCode } = options; + const now = new Date(); + const window = { + from: kstDateString(now), + to: kstDateString(now, options.days ?? DEFAULT_EVENT_LOOKAHEAD_DAYS), + }; + const budget = new CallBudget( + this.apiFactory.kopisDailyLimit(), + await this.recorder.usedToday(), + ); + const api = this.apiFactory.createKopis(budget); + const run = await this.recorder.start('kopis', regionCode); + const result: KopisSyncResult = { + regionCode, + window, + fetched: 0, + inserted: 0, + updated: 0, + hidden: 0, + skipped: {}, + changed: 0, + detailed: 0, + detailFailed: 0, + detailRemaining: 0, + venuesSynced: 0, + venueFailed: 0, + venueCoordsRejected: [], + stoppedByBudget: false, + calls: {}, + errors: [], + }; + + try { + await this.syncList(api, regionCode, window, result); + const changed = result.stoppedByBudget + ? new Set() + : await this.changedSinceYesterday(api, regionCode, window, result); + if (!result.stoppedByBudget) { + await this.syncDetails( + api, + budget, + regionCode, + window.from, + changed, + result, + ); + } + if (!result.stoppedByBudget) { + await this.syncVenues(api, budget, regionCode, window.from, result); + } + await this.applyVenueLocations(regionCode); + result.detailRemaining = await this.detailPendingQuery( + regionCode, + window.from, + new Set(), + ).getCount(); + } catch (error) { + result.errors.unshift(errorText(error)); + throw error; + } finally { + result.calls = budget.spent(); + await this.recorder.finish(run, { + fetched: result.fetched, + inserted: result.inserted, + updated: result.updated, + hidden: result.hidden, + calls: result.calls, + errors: [ + ...result.errors, + ...result.venueCoordsRejected.map( + (name) => `${name}: 좌표가 주소(시·도)와 맞지 않아 비움`, + ), + ], + }); + this.report(result); + } + return result; + } + + /** 1. 목록 → upsert, 목록에서 없어진 공연 숨김 */ + private async syncList( + api: KopisApi, + regionCode: string, + window: { from: string; to: string }, + result: KopisSyncResult, + ): Promise { + const runStart = new Date(); + const existing = await this.events.find({ + where: { source: SOURCE }, + select: { + id: true, + sourceId: true, + status: true, + ldongRegnCd: true, + startDate: true, + endDate: true, + }, + }); + const bySourceId = new Map(existing.map((e) => [e.sourceId, e])); + + const rows = new Map>(); + let complete = false; + for (let page = 1; page <= MAX_PAGES; page += 1) { + let items: KopisItem[]; + try { + items = await api.listPerformances({ + stdate: toYmd(window.from), + eddate: toYmd(window.to), + signgucode: regionCode, + cpage: page, + rows: PAGE_SIZE, + }); + } catch (error) { + if (error instanceof BudgetExceededError) { + result.stoppedByBudget = true; + result.errors.push(error.message); + break; + } + throw error; + } + result.fetched += items.length; + for (const item of items) { + const mapped = kopisListItemToEvent(item); + if ('skip' in mapped) { + this.skip(result, mapped.skip); + continue; + } + const { area, ...columns } = mapped; + // 지역 조건을 서버가 무시하더라도 다른 시·도 공연은 넣지 않는다 + const sido = sidoCodeFromAddress(area); + if (sido !== null && sido !== regionCode) { + this.skip(result, 'other_region'); + continue; + } + if (rows.has(mapped.sourceId)) { + this.skip(result, 'duplicate'); // 쪽 경계에서 같은 공연이 두 번 올 수 있다 + continue; + } + rows.set(mapped.sourceId, { + ...columns, + source: SOURCE, + kind: 'performance', + ldongRegnCd: regionCode, + status: 'active', + lastSeenAt: runStart, + updatedAt: runStart, + }); + if (bySourceId.has(mapped.sourceId)) result.updated += 1; + else result.inserted += 1; + } + if (items.length < PAGE_SIZE) { + complete = true; + break; + } + } + + await this.upsert([...rows.values()]); + if (!complete) return; + + // 받은 기간과 겹치는데 이번 목록에 없는 공연은 숨긴다(취소·삭제) + const inWindowBefore = existing.filter( + (e) => + e.status === 'active' && + e.ldongRegnCd === regionCode && + e.endDate >= window.from && + e.startDate <= window.to, + ); + const missing = inWindowBefore.filter((e) => !rows.has(e.sourceId)).length; + if (isSuspiciousRemoval(inWindowBefore.length, missing)) { + result.errors.push( + `기간 안 공연 ${inWindowBefore.length}건 중 ${missing}건이 목록에 없어 원본 장애로 보고 숨김 처리를 건너뜀`, + ); + return; + } + const hidden = await this.events + .createQueryBuilder() + .update(FestivalEvent) + .set({ status: 'hidden' }) + .where('source = :source', { source: SOURCE }) + .andWhere('ldong_regn_cd = :region', { region: regionCode }) + .andWhere('status = :active', { active: 'active' }) + .andWhere('end_date >= :from AND start_date <= :to', window) + .andWhere('(last_seen_at IS NULL OR last_seen_at < :runStart)', { + runStart, + }) + .execute(); + result.hidden = hidden.affected ?? 0; + } + + private skip(result: KopisSyncResult, reason: SkipReason): void { + result.skipped[reason] = (result.skipped[reason] ?? 0) + 1; + } + + private async upsert(rows: Partial[]): Promise { + const overwrite = LIST_OVERWRITE_PROPS.map( + (prop) => + this.events.metadata.findColumnWithPropertyName(prop)!.databaseName, + ); + for (let i = 0; i < rows.length; i += UPSERT_CHUNK) { + await this.events + .createQueryBuilder() + .insert() + .into(FestivalEvent) + .values(rows.slice(i, i + UPSERT_CHUNK)) + .orUpdate(overwrite, ['source', 'source_id']) + .execute(); + } + } + + /** + * 어제(한국 날짜) 이후 등록·수정된 공연 id — 목록에는 수정일이 없어 따로 묻는다. + * 실패해도 동기화는 계속한다(7일 주기 재조회가 받쳐 준다). + */ + private async changedSinceYesterday( + api: KopisApi, + regionCode: string, + window: { from: string; to: string }, + result: KopisSyncResult, + ): Promise> { + const changed = new Set(); + const afterdate = toYmd(kstDateString(new Date(), -1)); + try { + for (let page = 1; page <= MAX_PAGES; page += 1) { + const items = await api.listPerformances({ + stdate: toYmd(window.from), + eddate: toYmd(window.to), + signgucode: regionCode, + cpage: page, + rows: PAGE_SIZE, + afterdate, + }); + for (const item of items) { + if (typeof item.mt20id === 'string') changed.add(item.mt20id.trim()); + } + if (items.length < PAGE_SIZE) break; + } + } catch (error) { + if (error instanceof BudgetExceededError) result.stoppedByBudget = true; + result.errors.push(`최근 수정 공연 조회 실패: ${errorText(error)}`); + } + result.changed = changed.size; + return changed; + } + + /** 상세를 받아야 하는 공연 — 새 공연 · 바뀐 공연 · 오래된 공연 */ + private detailPendingQuery( + regionCode: string, + today: string, + changed: Set, + ) { + const qb = this.events + .createQueryBuilder('e') + .select(['e.id', 'e.sourceId']) + .where('e.source = :source', { source: SOURCE }) + .andWhere('e.ldong_regn_cd = :region', { region: regionCode }) + .andWhere('e.status = :active', { active: 'active' }) + .andWhere('e.end_date >= :today', { today }); + const stale = new Date(Date.now() - DETAIL_REFRESH_DAYS * DAY_MS); + if (changed.size > 0) { + qb.andWhere( + '(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale OR e.source_id IN (:...changed))', + { stale, changed: [...changed] }, + ); + } else { + qb.andWhere( + '(e.detail_synced_at IS NULL OR e.detail_synced_at < :stale)', + { + stale, + }, + ); + } + return qb.orderBy('e.start_date', 'ASC').addOrderBy('e.id', 'ASC'); + } + + /** 2. 공연상세 → 요금·공연 시간·소개·예매처·공연시설 연결 */ + private async syncDetails( + api: KopisApi, + budget: CallBudget, + regionCode: string, + today: string, + changed: Set, + result: KopisSyncResult, + ): Promise { + const pending = await this.detailPendingQuery( + regionCode, + today, + changed, + ).getMany(); + const venueIds = new Map(); + let consecutiveFailures = 0; + for (const event of pending) { + if (!budget.canSpend(KOPIS_OPERATIONS.detail)) { + result.stoppedByBudget = true; + break; + } + try { + const db = await api.getPerformance(event.sourceId); + if (!db) throw new Error('상세가 비어 있음'); + const d = kopisDetail(db); + const venueId = d.venueSourceId + ? await this.ensureVenue( + d.venueSourceId, + d.eventPlace ?? '공연장', + venueIds, + ) + : null; + await this.events.update(event.id, { + // 상세가 주는 값은 상세가 기준이다. 이름·기간·장르·포스터는 비어 있으면 목록 값을 둔다 + ...(d.name ? { name: d.name } : {}), + ...(d.startDate && d.endDate + ? { startDate: d.startDate, endDate: d.endDate } + : {}), + ...(d.genre ? { genre: d.genre } : {}), + ...(d.eventPlace ? { eventPlace: d.eventPlace } : {}), + ...(d.imageUrl + ? { imageUrl: d.imageUrl, imageThumbUrl: d.imageUrl } + : {}), + feeText: d.feeText, + playtimeText: d.playtimeText, + overview: d.overview, + bookingUrl: d.bookingUrl, + sourceModifiedAt: d.sourceModifiedAt, + venueId, + detailSyncedAt: new Date(), + }); + result.detailed += 1; + consecutiveFailures = 0; + } catch (error) { + if (error instanceof BudgetExceededError) { + result.stoppedByBudget = true; + result.errors.push(error.message); + break; + } + result.detailFailed += 1; + consecutiveFailures += 1; + result.errors.push(`${event.sourceId}: ${errorText(error)}`); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + result.errors.push( + `상세 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`, + ); + break; + } + } + } + } + + /** + * 공연시설 행을 찾거나 이름만으로 만든다(주소·좌표는 3단계에서 채운다). + * 이미 있는 시설은 건드리지 않는다. + */ + private async ensureVenue( + mt10id: string, + fallbackName: string, + cache: Map, + ): Promise { + const cached = cache.get(mt10id); + if (cached) return cached; + await this.venues + .createQueryBuilder() + .insert() + .into(EventVenue) + .values({ source: SOURCE, sourceId: mt10id, name: fallbackName }) + .orIgnore() + .execute(); + const venue = await this.venues.findOneOrFail({ + where: { source: SOURCE, sourceId: mt10id }, + select: { id: true }, + }); + cache.set(mt10id, venue.id); + return venue.id; + } + + /** 3. 공연시설 상세 — 진행 중·예정 공연이 쓰는 시설 중 새 시설과 오래된 시설만 */ + private async syncVenues( + api: KopisApi, + budget: CallBudget, + regionCode: string, + today: string, + result: KopisSyncResult, + ): Promise { + const stale = new Date(Date.now() - VENUE_REFRESH_DAYS * DAY_MS); + const pending = await this.venues + .createQueryBuilder('v') + .select(['v.id', 'v.sourceId', 'v.name']) + .where('v.source = :source', { source: SOURCE }) + .andWhere('(v.synced_at IS NULL OR v.synced_at < :stale)', { stale }) + .andWhere( + `EXISTS (SELECT 1 FROM festival_event e WHERE e.venue_id = v.id AND e.source = :source + AND e.status = 'active' AND e.end_date >= :today AND e.ldong_regn_cd = :region)`, + { today, region: regionCode }, + ) + .orderBy('v.source_id', 'ASC') + .getMany(); + let consecutiveFailures = 0; + for (const venue of pending) { + if (!budget.canSpend(KOPIS_OPERATIONS.venue)) { + result.stoppedByBudget = true; + break; + } + try { + const db = await api.getVenue(venue.sourceId); + if (!db) throw new Error('시설 상세가 비어 있음'); + const { coordsRejected, ...columns } = kopisVenue(db, venue.name); + await this.venues.update(venue.id, { + ...columns, + syncedAt: new Date(), + }); + if (coordsRejected) result.venueCoordsRejected.push(columns.name); + result.venuesSynced += 1; + consecutiveFailures = 0; + } catch (error) { + if (error instanceof BudgetExceededError) { + result.stoppedByBudget = true; + result.errors.push(error.message); + break; + } + result.venueFailed += 1; + consecutiveFailures += 1; + result.errors.push(`시설 ${venue.sourceId}: ${errorText(error)}`); + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + result.errors.push( + `시설 조회가 ${MAX_CONSECUTIVE_FAILURES}번 연속 실패해 멈춤`, + ); + break; + } + } + } + } + + /** 4. 공연시설의 좌표·주소·전화를 공연에 복사한다(시설 좌표를 고치면 다음 실행에 공연도 바뀐다) */ + private async applyVenueLocations(regionCode: string): Promise { + await this.events.query( + `UPDATE festival_event e + SET lat = v.lat, lng = v.lng, address = v.address, phone = v.phone, updated_at = now() + FROM event_venue v + WHERE e.venue_id = v.id AND e.source = $1 AND e.ldong_regn_cd = $2 AND v.synced_at IS NOT NULL + AND (e.lat IS DISTINCT FROM v.lat OR e.lng IS DISTINCT FROM v.lng + OR e.address IS DISTINCT FROM v.address OR e.phone IS DISTINCT FROM v.phone)`, + [SOURCE, regionCode], + ); + } + + private report(r: KopisSyncResult): void { + const skipped = Object.entries(r.skipped) + .map(([k, v]) => `${k} ${v}`) + .join(', '); + this.logger.log( + `KOPIS 공연 ${r.regionCode} (${r.window.from} ~ ${r.window.to}) ${r.fetched}건 → 새 공연 ${r.inserted} · 갱신 ${r.updated} · 숨김 ${r.hidden}` + + (skipped ? ` · 건너뜀(${skipped})` : ''), + ); + this.logger.log( + `상세 ${r.detailed}건 받음(어제 이후 바뀐 공연 ${r.changed}) · 실패 ${r.detailFailed} · 남은 상세 ${r.detailRemaining}` + + ` / 공연시설 ${r.venuesSynced}곳 받음 · 실패 ${r.venueFailed}` + + (r.stoppedByBudget + ? ' — 오늘 한도에 닿아 멈춤, 내일 다시 실행하면 이어서 받는다' + : ''), + ); + for (const name of r.venueCoordsRejected) { + this.logger.warn( + `${name}: 좌표가 주소(시·도)와 맞지 않아 비움 — 근처 조회에서 빠진다`, + ); + } + 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/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts index 0a103c9..794e33c 100644 --- a/backend/src/modules/place/sync/place-sync.module.ts +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -7,6 +7,7 @@ import { PlaceCuration } from '../entities/place-curation.entity'; import { Place } from '../entities/place.entity'; import { PlaceSyncRun } from '../entities/place-sync-run.entity'; import { FestivalSync } from './festival.sync'; +import { KopisSync } from './kopis.sync'; import { PlaceDedupe } from './place-dedupe'; import { PlaceHoursReparse } from './place-hours-reparse'; import { PlaceSyncStatus } from './place-sync-status'; @@ -37,6 +38,7 @@ import { TourPetSync } from './tour-pet.sync'; TourPetSync, StoreSync, FestivalSync, + KopisSync, PlaceDedupe, PlaceHoursReparse, PlaceSyncStatus, @@ -44,6 +46,7 @@ import { TourPetSync } from './tour-pet.sync'; exports: [ TourKorSync, FestivalSync, + KopisSync, TourPetSync, StoreSync, PlaceDedupe, diff --git a/backend/src/modules/place/sync/public-data.client.ts b/backend/src/modules/place/sync/public-data.client.ts index 913da1b..d934cce 100644 --- a/backend/src/modules/place/sync/public-data.client.ts +++ b/backend/src/modules/place/sync/public-data.client.ts @@ -1,7 +1,7 @@ import { BudgetExceededError, CallBudget } from './call-budget'; /** - * 공공데이터포털(apis.data.go.kr) 공통 호출 규칙 — 관광공사·상가정보 클라이언트가 함께 쓴다. + * 공공데이터 공통 호출 규칙 — 관광공사·상가정보(apis.data.go.kr)·KOPIS 클라이언트가 함께 쓴다. * 호출마다 한도를 차감하고(재시도 포함 — 재시도도 실제 호출이다), 간격을 두고, 네트워크 오류·5xx 만 다시 시도한다. * **키는 어떤 오류 메시지에도 남기지 않는다.** 응답 해석은 API 마다 달라 하위 클래스가 맡는다. */ @@ -9,7 +9,7 @@ import { BudgetExceededError, CallBudget } from './call-budget'; const REQUEST_TIMEOUT_MS = 20_000; const MAX_ATTEMPTS = 3; const RETRY_DELAY_MS = 1_000; -const CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비) +const DEFAULT_CALL_INTERVAL_MS = 100; // 연속 호출 간격(서버 부담·초당 제한 대비) /** 게이트웨이가 한도 초과로 거부할 때의 표시(오류 코드 22) */ export const QUOTA_EXCEEDED_RE = @@ -42,6 +42,14 @@ export interface PublicDataClientOptions { sleep?: (ms: number) => Promise; } +/** API 마다 다른 호출 규칙 — 키 파라미터 이름, 호출 간격 */ +export interface PublicDataClientSettings { + /** 키를 싣는 쿼리 파라미터(공공데이터포털 serviceKey · KOPIS service) */ + keyParam?: string; + /** 연속 호출 최소 간격(ms) */ + intervalMs?: number; +} + const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -78,32 +86,42 @@ export function parseJsonOrThrow(body: string, operation: string): unknown { export abstract class PublicDataClient { private readonly fetchImpl: FetchLike; private readonly sleep: (ms: number) => Promise; + private readonly keyParam: string; + private readonly intervalMs: number; private lastCallAt = 0; protected constructor( private readonly options: PublicDataClientOptions, private readonly baseUrl: string, private readonly commonParams: Record, + settings: PublicDataClientSettings = {}, ) { this.fetchImpl = options.fetchImpl ?? fetch; this.sleep = options.sleep ?? defaultSleep; + this.keyParam = settings.keyParam ?? 'serviceKey'; + this.intervalMs = settings.intervalMs ?? DEFAULT_CALL_INTERVAL_MS; } /** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */ protected abstract parse(body: string, operation: string): Page; + /** + * @param operation 한도·오류 메시지에 쓰는 이름 + * @param path 주소 경로 — 없으면 operation. KOPIS 상세처럼 경로에 id 가 들어가는 호출에 쓴다 + */ async get( operation: string, params: Record, + path: string = operation, ): Promise { const query = new URLSearchParams({ - serviceKey: this.options.serviceKey, + [this.keyParam]: 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()}`; + const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`; let lastError: unknown; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { @@ -147,7 +165,7 @@ export abstract class PublicDataClient { } private async pace(): Promise { - const wait = this.lastCallAt + CALL_INTERVAL_MS - Date.now(); + const wait = this.lastCallAt + this.intervalMs - Date.now(); if (wait > 0) await this.sleep(wait); this.lastCallAt = Date.now(); } diff --git a/backend/src/modules/place/sync/public-data.factory.ts b/backend/src/modules/place/sync/public-data.factory.ts index a588626..d02845a 100644 --- a/backend/src/modules/place/sync/public-data.factory.ts +++ b/backend/src/modules/place/sync/public-data.factory.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { parsePositiveInt } from '@/shared/utils/env'; import { CallBudget } from './call-budget'; +import { KopisApi, KopisApiClient } from './kopis-api.client'; import { StoreApi, StoreApiClient } from './store-api.client'; import { TourApi, TourApiClient } from './tour-api.client'; @@ -10,11 +11,16 @@ import { TourApi, TourApiClient } from './tour-api.client'; const DEFAULT_TOUR_DAILY_LIMIT = 1_000; /** 상가정보 개발 계정 하루 한도(대구 3개 구 적재는 30회 안팎) */ const STORE_DAILY_LIMIT = 10_000; +/** + * KOPIS 오퍼레이션별 하루 상한 — 공식 한도는 밝혀져 있지 않아 보수적으로 둔다. + * 대구·경북 첫 적재가 상세 250회·시설 100회 안팎이다(2026-11-03 진단: 대구 153 · 경북 91 공연). + */ +const KOPIS_DAILY_LIMIT = 1_000; /** 한도 끝까지 쓰지 않고 남겨 두는 여유분 — 같은 날 점검 스크립트 등으로 확인할 여지를 둔다 */ const SAFETY_MARGIN = 20; /** - * 공공데이터포털 클라이언트를 만든다(키 하나로 관광공사·상가정보를 모두 부른다). + * 공공데이터 클라이언트를 만든다 — 공공데이터포털 키 하나로 관광공사·상가정보를, KOPIS 는 KOPIS 키로 부른다. * 테스트에서는 이 프로바이더를 가짜로 바꿔 끼운다. */ @Injectable() @@ -42,12 +48,33 @@ export class PublicDataApiFactory { return new StoreApiClient({ serviceKey: this.serviceKey(), budget }); } + kopisDailyLimit(): number { + return KOPIS_DAILY_LIMIT - SAFETY_MARGIN; + } + + createKopis(budget: CallBudget): KopisApi { + return new KopisApiClient({ + serviceKey: this.requireKey( + 'KOPIS_SERVICE_KEY', + 'KOPIS 에서 발급받은 서비스키', + ), + budget, + }); + } + private serviceKey(): string { - const key = this.config.get('PUBLIC_DATA_SERVICE_KEY')?.trim(); + return this.requireKey( + 'PUBLIC_DATA_SERVICE_KEY', + '공공데이터포털 Decoding 키', + ); + } + + private requireKey(name: string, description: string): string { + const key = this.config.get(name)?.trim(); if (!key || /change[-_]?me/i.test(key)) { // 키 값은 어떤 경우에도 출력하지 않는다 throw new Error( - 'PUBLIC_DATA_SERVICE_KEY 가 설정되지 않았습니다 — backend/.env.{환경} 에 공공데이터포털 Decoding 키를 넣으세요', + `${name} 가 설정되지 않았습니다 — backend/.env.{환경} 에 ${description}를 넣으세요`, ); } return key; diff --git a/backend/src/shared/utils/geo.spec.ts b/backend/src/shared/utils/geo.spec.ts index 788c905..4210aa6 100644 --- a/backend/src/shared/utils/geo.spec.ts +++ b/backend/src/shared/utils/geo.spec.ts @@ -1,4 +1,4 @@ -import { isInKorea } from './geo'; +import { isInKorea, isInSido, sidoCodeFromAddress } from './geo'; describe('isInKorea', () => { it('대구 좌표는 한국 안이다', () => { @@ -14,3 +14,33 @@ describe('isInKorea', () => { expect(isInKorea(Number.NaN, 128.5)).toBe(false); }); }); + +describe('sidoCodeFromAddress', () => { + it('주소 앞머리로 대구·경북 코드를 찾는다', () => { + expect(sidoCodeFromAddress('대구광역시 중구 태평로 141')).toBe('27'); + expect(sidoCodeFromAddress('경상북도 경주시 알천북로 1')).toBe('47'); + expect(sidoCodeFromAddress(' 경북 포항시 북구')).toBe('47'); + }); + + it('모르는 지역이거나 비어 있으면 null', () => { + expect(sidoCodeFromAddress('부산광역시 해운대구')).toBeNull(); + expect(sidoCodeFromAddress(null)).toBeNull(); + }); +}); + +describe('isInSido', () => { + it('대구 도심·군위 좌표는 대구 안이다', () => { + expect(isInSido('27', 35.8761, 128.5938)).toBe(true); + expect(isInSido('27', 36.2428, 128.5728)).toBe(true); + }); + + it('대구 주소에 경주 좌표(2026-11-03 KOPIS 실제 사례)는 대구 밖이다', () => { + expect(isInSido('27', 35.847189, 129.212306)).toBe(false); + expect(isInSido('47', 35.847189, 129.212306)).toBe(true); + }); + + it('영역을 모르는 시·도는 한국 안인지만 본다', () => { + expect(isInSido('26', 35.1587, 129.1604)).toBe(true); + expect(isInSido('26', 0, 0)).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/geo.ts b/backend/src/shared/utils/geo.ts index 765c129..51b2633 100644 --- a/backend/src/shared/utils/geo.ts +++ b/backend/src/shared/utils/geo.ts @@ -20,3 +20,44 @@ export function isInKorea(lat: number, lng: number): boolean { lng <= b.lngMax ); } + +interface Bounds { + latMin: number; + latMax: number; + lngMin: number; + lngMax: number; +} + +/** + * 시·도 영역 대략값(법정동 시·도 코드 2자리) — 원본 좌표가 주소와 다른 지역을 가리키는지 본다. + * 2026-11-03 KOPIS 공연시설 중 대구 중구 주소인데 경주 좌표(경도 129.21)인 곳이 있었다. + * 동기화 범위(대구·경북)만 둔다. 없는 코드는 한국 영역만 확인한다. + */ +const SIDO_BOUNDS: Record = { + // 대구 — 2023년 편입한 군위군 포함 + '27': { latMin: 35.55, latMax: 36.36, lngMin: 128.3, lngMax: 128.9 }, + // 경북 — 울릉도·독도 포함 + '47': { latMin: 35.55, latMax: 37.6, lngMin: 127.75, lngMax: 131.95 }, +}; + +/** 주소 앞머리 → 시·도 코드 (동기화 범위만) */ +const SIDO_ADDRESS_PREFIXES: Array<[RegExp, string]> = [ + [/^대구/, '27'], + [/^(경상북도|경북)/, '47'], +]; + +/** 주소('대구광역시 중구 …')에서 시·도 코드를 찾는다. 모르는 지역이면 null */ +export function sidoCodeFromAddress(address: string | null): string | null { + const text = address?.trim() ?? ''; + return SIDO_ADDRESS_PREFIXES.find(([re]) => re.test(text))?.[1] ?? null; +} + +/** 좌표가 그 시·도 안인지 본다. 영역을 모르는 코드면 한국 안인지만 본다 */ +export function isInSido(sidoCode: string, lat: number, lng: number): boolean { + if (!isInKorea(lat, lng)) return false; + const b = SIDO_BOUNDS[sidoCode]; + if (!b) return true; + return ( + lat >= b.latMin && lat <= b.latMax && lng >= b.lngMin && lng <= b.lngMax + ); +} diff --git a/backend/test/kopis-sync.e2e-spec.ts b/backend/test/kopis-sync.e2e-spec.ts new file mode 100644 index 0000000..a7f1b12 --- /dev/null +++ b/backend/test/kopis-sync.e2e-spec.ts @@ -0,0 +1,235 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DataSource } from 'typeorm'; + +import { PlaceSyncCliModule } from '@/commands/place-sync.cli.module'; +import { EventVenue } from '@/modules/place/entities/event-venue.entity'; +import { FestivalEvent } from '@/modules/place/entities/festival-event.entity'; +import { CallBudget } from '@/modules/place/sync/call-budget'; +import type { + KopisApi, + KopisItem, +} from '@/modules/place/sync/kopis-api.client'; +import { KopisSync } from '@/modules/place/sync/kopis.sync'; +import { PublicDataApiFactory } from '@/modules/place/sync/public-data.factory'; +import { kstDateString } from '@/shared/utils/kst'; + +/** + * KOPIS 공연 동기화 — 가짜 KOPIS 응답으로 목록 upsert·상세·공연시설·좌표 복사·숨김·"바뀐 공연만 상세"를 실제 DB 로 검증한다. + * 실제 데이터를 건드리지 않도록 없는 시·도 코드 '99' 와 'PFE2E'·'FCE2E' id 만 쓴다. + */ +const REGION = '99'; +const kopisDay = (addDays: number) => + kstDateString(new Date(), addDays).replace(/-/g, '.'); + +const listItem = (id: string, extra: Partial = {}): KopisItem => ({ + mt20id: id, + prfnm: `시험 공연 ${id}`, + prfpdfrom: kopisDay(0), + prfpdto: kopisDay(5), + fcltynm: '시험극장', + poster: `http://www.kopis.or.kr/upload/pfmPoster/PF_${id}.gif`, + genrenm: '연극', + prfstate: '공연예정', + ...extra, +}); + +const DETAILS: Record = { + PFE2E1: { + mt20id: 'PFE2E1', + mt10id: 'FCE2E1', + prfnm: '시험 공연 PFE2E1', + prfpdfrom: kopisDay(0), + prfpdto: kopisDay(5), + fcltynm: '시험극장 (소극장)', + pcseguidance: '전석 20,000원', + dtguidance: '금요일(19:30)', + prfage: '만 7세 이상', + updatedate: '2026-11-03 15:00:03.094823', + relates: { + relate: [{ relatenm: '예매', relateurl: 'https://ticket.test/1' }], + }, + }, + PFE2E2: { + mt20id: 'PFE2E2', + mt10id: 'FCE2E2', + prfnm: '시험 공연 PFE2E2', + prfpdfrom: kopisDay(10), + prfpdto: kopisDay(10), + fcltynm: '좌표가 틀린 극장', + }, +}; + +const VENUES: Record = { + // 주소 시·도를 모르는 곳 — 한국 안 좌표면 쓴다 + FCE2E1: { + fcltynm: '시험극장', + adres: '시험시 시험구 1', + la: '36.6', + lo: '130.6', + telno: '053-000-0000', + }, + // 대구 주소에 경주 좌표 — 비운다 + FCE2E2: { + fcltynm: '좌표가 틀린 극장', + adres: '대구광역시 중구 북성로 96', + la: '35.847189', + lo: '129.212306', + }, +}; + +/** 실행마다 바꿔 끼우는 가짜 응답 */ +const fake = { + list: [] as KopisItem[], + changed: [] as KopisItem[], +}; + +function fakeApi(budget: CallBudget): KopisApi { + return { + listPerformances: (params) => { + budget.take('pblprfr'); + if (params.signgucode !== REGION || params.cpage > 1) { + return Promise.resolve([]); + } + return Promise.resolve(params.afterdate ? fake.changed : fake.list); + }, + getPerformance: (id) => { + budget.take('pblprfr-detail'); + return Promise.resolve(DETAILS[id] ?? null); + }, + getVenue: (id) => { + budget.take('prfplc-detail'); + return Promise.resolve(VENUES[id] ?? null); + }, + }; +} + +describe('KOPIS 공연 동기화 (e2e)', () => { + let moduleRef: TestingModule; + let ds: DataSource; + let sync: KopisSync; + + const cleanup = async () => { + await ds.query( + `DELETE FROM festival_event WHERE source = 'kopis' AND source_id LIKE 'PFE2E%'`, + ); + await ds.query( + `DELETE FROM event_venue WHERE source = 'kopis' AND source_id LIKE 'FCE2E%'`, + ); + await ds.query( + `DELETE FROM place_sync_run WHERE job = 'kopis' AND scope = $1`, + [REGION], + ); + }; + const event = (id: string) => + ds.getRepository(FestivalEvent).findOneByOrFail({ + source: 'kopis', + sourceId: id, + }); + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [PlaceSyncCliModule], + }) + .overrideProvider(PublicDataApiFactory) + .useValue({ + kopisDailyLimit: () => 1_000, + createKopis: (budget: CallBudget) => fakeApi(budget), + }) + .compile(); + await moduleRef.init(); + ds = moduleRef.get(DataSource); + await ds.runMigrations(); + await cleanup(); + sync = moduleRef.get(KopisSync); + }); + + afterAll(async () => { + await cleanup(); + await moduleRef.close(); + }); + + it('첫 실행: 목록·상세·공연시설을 받고 시설 좌표를 공연에 복사한다', async () => { + fake.list = [ + listItem('PFE2E1'), + listItem('PFE2E1'), // 같은 공연이 두 번 + listItem('PFE2E2', { prfpdfrom: kopisDay(10), prfpdto: kopisDay(10) }), + listItem('PFE2E3', { prfstate: '공연완료' }), + listItem('PFE2E4', { area: '경상북도' }), // 다른 시·도 + ]; + fake.changed = []; + + const result = await sync.run({ regionCode: REGION }); + + expect(result).toMatchObject({ + inserted: 2, + hidden: 0, + detailed: 2, + detailFailed: 0, + detailRemaining: 0, + venuesSynced: 2, + venueCoordsRejected: ['좌표가 틀린 극장'], + stoppedByBudget: false, + skipped: { duplicate: 1, ended: 1, other_region: 1 }, + calls: { pblprfr: 2, 'pblprfr-detail': 2, 'prfplc-detail': 2 }, + }); + + const e1 = await event('PFE2E1'); + expect(e1).toMatchObject({ + kind: 'performance', + ldongRegnCd: REGION, + status: 'active', + genre: '연극', + eventPlace: '시험극장 (소극장)', + feeText: '전석 20,000원', + playtimeText: '금요일(19:30)', + overview: '관람 연령: 만 7세 이상', + bookingUrl: 'https://ticket.test/1', + imageUrl: 'https://kopis.or.kr/upload/pfmPoster/PF_PFE2E1.gif', + lat: 36.6, + lng: 130.6, + address: '시험시 시험구 1', + phone: '053-000-0000', + }); + expect(e1.detailSyncedAt).not.toBeNull(); + const venue = await ds + .getRepository(EventVenue) + .findOneByOrFail({ id: e1.venueId! }); + expect(venue).toMatchObject({ sourceId: 'FCE2E1', name: '시험극장' }); + + // 좌표가 주소와 맞지 않는 시설의 공연은 좌표 없이 남는다(근처 조회에서만 빠진다) + expect(await event('PFE2E2')).toMatchObject({ + lat: null, + lng: null, + address: '대구광역시 중구 북성로 96', + }); + }); + + it('바뀐 것이 없으면 상세·시설을 다시 받지 않고, 장소 이름도 목록 값으로 덮지 않는다', async () => { + const result = await sync.run({ regionCode: REGION }); + + expect(result).toMatchObject({ + updated: 2, + inserted: 0, + detailed: 0, + venuesSynced: 0, + calls: { pblprfr: 2 }, + }); + expect((await event('PFE2E1')).eventPlace).toBe('시험극장 (소극장)'); + }); + + it('목록에서 없어진 공연은 숨기고, 어제 이후 바뀐 공연은 상세를 다시 받는다', async () => { + fake.list = [listItem('PFE2E1')]; + fake.changed = [listItem('PFE2E1')]; + + const result = await sync.run({ regionCode: REGION }); + + expect(result).toMatchObject({ + hidden: 1, + changed: 1, + detailed: 1, + calls: { pblprfr: 2, 'pblprfr-detail': 1 }, + }); + expect((await event('PFE2E2')).status).toBe('hidden'); + expect((await event('PFE2E1')).status).toBe('active'); + }); +});