From a04f73337a6b2c6dda38b82c894c7f4ca88a0400 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 27 Jul 2026 20:11:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[fix]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B3=B5=EC=97=B0=20=EC=88=98=EB=8F=99=20=EC=88=98=EC=A7=91=20?= =?UTF-8?q?=EC=8B=9C=20visit=C2=B7=EC=9E=A5=EB=A5=B4=C2=B7alias=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 어드민이 KOPIS에서 공연을 검색해 수동으로 수집을 트리거하는 기능은 자동 스케줄러의 매칭에서 누락된 공연을 구제하는 용도인데, 자동 배치 잡과 동일한 게이트(내한 여부, alias 매칭)를 그대로 적용해 누락 원인과 동일한 이유로 수동 수집도 막히는 문제가 있었다. - collect_and_save_concert(): visit != 'Y', has_match 사전 게이트 제거 - search_concerts(): genrenm=GGGA 장르 제약 제거 (전체 장르 검색 대상) - 자동 배치 잡(run_new_concert_collect)의 매칭·장르 필터는 그대로 유지 Co-Authored-By: Claude Sonnet 4.6 --- collectors/kopis.py | 8 ++++++-- scheduler.py | 15 +++++---------- tests/test_api.py | 6 +++--- tests/test_scheduler.py | 27 +++++++++++---------------- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/collectors/kopis.py b/collectors/kopis.py index 91c63d0..d0f3f85 100644 --- a/collectors/kopis.py +++ b/collectors/kopis.py @@ -124,12 +124,16 @@ def _parse_concert(item: ET.Element) -> dict: def search_concerts(title: str) -> list[dict]: - """공연명으로 KOPIS 검색. 2020-01-01~1년 후 범위, 최대 20건 반환.""" + """공연명으로 KOPIS 검색 (전체 장르 대상). 2020-01-01~1년 후 범위, 최대 20건 반환. + + 어드민이 자동 매칭에서 누락된 공연을 수동으로 찾는 용도이므로 + genrenm(장르) 제약 없이 KOPIS 전체 장르를 검색 대상으로 한다. + """ today = datetime.date.today() stdate = _SEARCH_STDATE eddate = (today + datetime.timedelta(days=_DEFAULT_LOOKAHEAD_DAYS)).strftime("%Y%m%d") params = { - **_DEFAULT_PARAMS, + "service": _API_KEY, "shprfnm": title, "stdate": stdate, "eddate": eddate, diff --git a/scheduler.py b/scheduler.py index f7746f8..604d4bd 100644 --- a/scheduler.py +++ b/scheduler.py @@ -657,8 +657,10 @@ def run_setlist_collect() -> None: def collect_and_save_concert(kopis_id: str) -> dict: """단건 KOPIS 공연을 수집해 alias 매칭 후 DB에 저장한다. - KOPIS에 데이터가 없으면 {"status": "not_found"}, 내한 공연이 아니거나 - alias 매칭이 없으면 {"status": "skipped", "reason": ...}, 성공 시 + 어드민이 KOPIS에서 직접 골라 트리거하는 수집이므로 (자동 매칭에서 누락된 공연을 + 구제하는 용도) 내한 여부·alias 매칭 여부와 무관하게 저장한다. + + KOPIS에 데이터가 없으면 {"status": "not_found"}, 성공 시 {"status": "ok", "concert_id", "title", "matched_artists"}를 반환한다. 저장 직후 재조회에 실패하면 (있어선 안 되는 내부 불일치) RuntimeError를 발생시킨다. """ @@ -666,14 +668,6 @@ def collect_and_save_concert(kopis_id: str) -> dict: if concert is None: logger.warning("KOPIS 공연 데이터 없음: kopis_id=%s", kopis_id) return {"status": "not_found"} - if concert.get("visit") != "Y": - logger.info("내한 공연 아님 — 저장 건너뜀: kopis_id=%s", kopis_id) - return {"status": "skipped", "reason": "not_touring"} - - aliases = get_all_aliases() - if not has_match(concert, aliases): - logger.info("alias 매칭 없음 — 저장 건너뜀: kopis_id=%s", kopis_id) - return {"status": "skipped", "reason": "no_alias_match"} save_concerts([concert], use_prfstate=True) @@ -681,6 +675,7 @@ def collect_and_save_concert(kopis_id: str) -> dict: if concert_row is None: raise RuntimeError(f"공연 저장 후 조회 실패: kopis_id={kopis_id}") + aliases = get_all_aliases() matched_artists = [] matches, _ = match_concert(concert_row, aliases) if matches: diff --git a/tests/test_api.py b/tests/test_api.py index 6124fa0..de25c2d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -74,14 +74,14 @@ def test_returns_404_when_not_found(self, client): assert res.status_code == 404 def test_returns_200_with_success_false_when_skipped(self, client): - """비즈니스 스킵(내한 아님 등)이면 200 + success:false를 반환해야 한다.""" + """비즈니스 스킵이면 200 + success:false를 반환해야 한다.""" with patch( "api.collect_and_save_concert", - return_value={"status": "skipped", "reason": "not_touring"}, + return_value={"status": "skipped", "reason": "some_reason"}, ): res = client.post("/collect/concert", json={"kopis_id": "PF001"}, headers=_AUTH) assert res.status_code == 200 - assert res.json() == {"success": False, "reason": "not_touring"} + assert res.json() == {"success": False, "reason": "some_reason"} def test_called_with_kopis_id(self, client): """collect_and_save_concert가 kopis_id로 직접 호출되어야 한다.""" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 3139ae4..6d63078 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -654,29 +654,25 @@ def test_returns_not_found_when_kopis_has_no_data(self): with patch("scheduler.kopis.collect_by_id", return_value=None): assert collect_and_save_concert("PF000") == {"status": "not_found"} - def test_returns_skipped_when_not_touring(self): - """내한 공연이 아니면 status:skipped, reason:not_touring을 반환해야 한다.""" + def test_saves_even_when_not_touring(self): + """내한 공연이 아니어도 저장된다 (어드민이 직접 고른 공연이므로 visit 무관).""" concert = {**self._CONCERT_RAW, "visit": "N"} - with patch("scheduler.kopis.collect_by_id", return_value=concert): - result = collect_and_save_concert("PF001") - assert result == {"status": "skipped", "reason": "not_touring"} - - def test_returns_skipped_when_no_alias_match(self): - """alias 매칭이 없으면 status:skipped, reason:no_alias_match를 반환해야 한다.""" with ( - patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), + patch("scheduler.kopis.collect_by_id", return_value=concert), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=False), + patch("scheduler.save_concerts") as mock_save, + patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW), + patch("scheduler.match_concert", return_value=([], [{"concert_id": 10}])), ): result = collect_and_save_concert("PF001") - assert result == {"status": "skipped", "reason": "no_alias_match"} + mock_save.assert_called_once() + assert result["status"] == "ok" def test_returns_ok_with_matched_artists_on_success(self): """매칭 성공 시 status:ok와 matched_artists를 반환해야 한다.""" with ( patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=True), patch("scheduler.save_concerts"), patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW), patch( @@ -695,16 +691,16 @@ def test_returns_ok_with_matched_artists_on_success(self): } def test_returns_ok_with_empty_matches_when_no_match_found(self): - """저장은 됐지만 실제 매칭이 0건이면 matched_artists 빈 리스트로 성공 반환해야 한다.""" + """alias 매칭이 없어도 저장되고, matched_artists 빈 리스트로 성공 반환해야 한다.""" with ( patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=True), - patch("scheduler.save_concerts"), + patch("scheduler.save_concerts") as mock_save, patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW), patch("scheduler.match_concert", return_value=([], [{"concert_id": 10}])), ): result = collect_and_save_concert("PF001") + mock_save.assert_called_once() assert result["status"] == "ok" assert result["matched_artists"] == [] @@ -713,7 +709,6 @@ def test_raises_when_db_lookup_fails_after_save(self): with ( patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), patch("scheduler.get_all_aliases", return_value=self._ALIASES), - patch("scheduler.has_match", return_value=True), patch("scheduler.save_concerts"), patch("scheduler.get_concert_by_kopis_id", return_value=None), pytest.raises(RuntimeError), From 9b99ec16152f175ffbfe04b0096cefbe0364551a Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Mon, 27 Jul 2026 21:08:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[feat]=20=EA=B3=B5=EC=97=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20title=C2=B7=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit concert 테이블은 어드민 수동 등록(kopis_id NULL)과 Data의 KOPIS 자동/수동 수집이 공유하는데, 기존에는 kopis_id 기준 중복 방지만 있어 동일 공연이 서로 다른 경로로 중복 저장될 수 있었다. - save_concerts(): INSERT 전 title·start_date·end_date 완전 일치 검사, 일치하는 공연이 있으면 해당 건만 건너뛰고 kopis_id를 반환 - collect_and_save_concert(): 중복 반환 시 status:skipped, reason:duplicate_title 응답 (api.py 기존 스킵 처리 관례 그대로 적용) - run_new_concert_collect()는 변경 없이 동일 save_concerts()를 사용 — 배치 잡은 중복 건만 스킵하고 나머지는 그대로 처리 Co-Authored-By: Claude Sonnet 4.6 --- db/repository.py | 34 +++++++++++++++++++++++++++-- scheduler.py | 10 ++++++--- tests/test_kopis.py | 47 +++++++++++++++++++++++++++++++++++------ tests/test_scheduler.py | 9 ++++++++ 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/db/repository.py b/db/repository.py index 65d075f..d61b52d 100644 --- a/db/repository.py +++ b/db/repository.py @@ -159,16 +159,42 @@ def save_releases(artist_id: int, releases: list[dict]) -> None: logger.info("릴리즈 저장 완료: %d / %d건 처리", saved, len(releases)) -def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> None: +def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set: """수집된 공연 목록을 concert 테이블에 저장한다. kopis_id 중복 시 무시. + title·start_date·end_date가 모두 일치하는 공연이 이미 존재하면(예: 어드민이 + kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛴다. + use_prfstate=True이면 prfstate → _KOPIS_STATUS_MAP으로 status를 결정한다. 기본값(False)은 배치 수집용 PENDING을 사용한다. + + 반환값: title·기간 중복으로 저장을 건너뛴 concert들의 kopis_id 집합 """ saved = 0 + duplicate_kopis_ids = set() for concert in concerts: try: with get_session() as session: + title_match = session.execute( + text(""" + SELECT id FROM concert + WHERE title = :title AND start_date = :start_date + AND end_date = :end_date + """), + { + "title": concert["prfnm"], + "start_date": concert["prfpdfrom"], + "end_date": concert["prfpdto"], + }, + ).fetchone() + if title_match is not None: + logger.info( + "동일 공연명·기간 이미 존재 — 저장 건너뜀: title=%s, kopis_id=%s", + concert["prfnm"], concert["kopis_id"], + ) + duplicate_kopis_ids.add(concert["kopis_id"]) + continue + status = ( _KOPIS_STATUS_MAP.get(concert.get("prfstate"), "PENDING") if use_prfstate @@ -237,7 +263,11 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> None: saved += 1 except SQLAlchemyError as e: logger.error("공연 저장 실패 — 건너뜀: kopis_id=%s, 오류=%s", concert["kopis_id"], e) - logger.info("공연 저장 완료: %d / %d건 처리", saved, len(concerts)) + logger.info( + "공연 저장 완료: %d / %d건 처리 (title·기간 중복 %d건 제외)", + saved, len(concerts), len(duplicate_kopis_ids), + ) + return duplicate_kopis_ids def get_completed_concerts() -> list[dict]: diff --git a/scheduler.py b/scheduler.py index 604d4bd..b633862 100644 --- a/scheduler.py +++ b/scheduler.py @@ -660,8 +660,10 @@ def collect_and_save_concert(kopis_id: str) -> dict: 어드민이 KOPIS에서 직접 골라 트리거하는 수집이므로 (자동 매칭에서 누락된 공연을 구제하는 용도) 내한 여부·alias 매칭 여부와 무관하게 저장한다. - KOPIS에 데이터가 없으면 {"status": "not_found"}, 성공 시 - {"status": "ok", "concert_id", "title", "matched_artists"}를 반환한다. + KOPIS에 데이터가 없으면 {"status": "not_found"}, 동일 title·기간의 공연이 + 이미 존재하면(예: 어드민 수동 등록과 중복) {"status": "skipped", + "reason": "duplicate_title"}, 성공 시 {"status": "ok", "concert_id", "title", + "matched_artists"}를 반환한다. 저장 직후 재조회에 실패하면 (있어선 안 되는 내부 불일치) RuntimeError를 발생시킨다. """ concert = kopis.collect_by_id(kopis_id) @@ -669,7 +671,9 @@ def collect_and_save_concert(kopis_id: str) -> dict: logger.warning("KOPIS 공연 데이터 없음: kopis_id=%s", kopis_id) return {"status": "not_found"} - save_concerts([concert], use_prfstate=True) + duplicate_kopis_ids = save_concerts([concert], use_prfstate=True) + if kopis_id in duplicate_kopis_ids: + return {"status": "skipped", "reason": "duplicate_title"} concert_row = get_concert_by_kopis_id(kopis_id) if concert_row is None: diff --git a/tests/test_kopis.py b/tests/test_kopis.py index a3004d0..25f394b 100644 --- a/tests/test_kopis.py +++ b/tests/test_kopis.py @@ -622,6 +622,7 @@ def _make_concert(self, **kwargs) -> dict: def test_insert_sql_contains_on_conflict(self): """INSERT SQL에 ON CONFLICT가 포함되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -637,6 +638,7 @@ def test_insert_sql_contains_on_conflict(self): def test_insert_sql_includes_poster_url(self): """INSERT SQL에 poster_url 컬럼이 포함되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -651,6 +653,7 @@ def test_insert_sql_includes_poster_url(self): def test_poster_url_param_passed(self): """INSERT 파라미터에 poster_url 값이 전달되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -665,6 +668,7 @@ def test_poster_url_param_passed(self): def test_insert_sql_includes_price(self): """INSERT SQL에 price 컬럼이 포함되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -679,6 +683,7 @@ def test_insert_sql_includes_price(self): def test_price_param_passed(self): """INSERT 파라미터에 price 값이 전달되어야 한다.""" mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -693,7 +698,7 @@ def test_price_param_passed(self): def test_booking_links_inserted_for_new_concert(self): """신규 공연 저장 시 relates가 concert_booking_link 테이블에 별도 INSERT되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -713,7 +718,7 @@ def test_booking_links_inserted_for_new_concert(self): def test_booking_links_inserted_for_existing_concert(self): """기존 공연(INSERT DO NOTHING)도 SELECT fallback으로 booking_link가 INSERT되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.side_effect = [None, (5,)] + mock_session.execute.return_value.fetchone.side_effect = [None, None, (5,)] with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -731,7 +736,7 @@ def test_booking_links_inserted_for_existing_concert(self): def test_booking_link_insert_has_on_conflict(self): """concert_booking_link INSERT에 ON CONFLICT DO NOTHING이 포함되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) mock_get_session.return_value.__exit__ = MagicMock(return_value=False) @@ -749,7 +754,7 @@ def test_booking_link_insert_has_on_conflict(self): def test_concert_image_inserted_for_new_concert(self): """신규 공연 저장 시 still_urls가 concert_image에 position 순으로 INSERT되어야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] concert = self._make_concert(still_urls=["http://still1.jpg", "http://still2.jpg"]) with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) @@ -768,7 +773,7 @@ def test_concert_image_inserted_for_new_concert(self): def test_concert_image_not_inserted_for_existing_concert(self): """기존 공연(RETURNING None)이면 concert_image INSERT가 실행되지 않아야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.side_effect = [None, (5,)] + mock_session.execute.return_value.fetchone.side_effect = [None, None, (5,)] concert = self._make_concert(still_urls=["http://still1.jpg"]) with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) @@ -780,10 +785,40 @@ def test_concert_image_not_inserted_for_existing_concert(self): ] assert len(image_inserts) == 0 + def test_skips_insert_when_title_and_dates_match_existing(self): + """title·start_date·end_date가 모두 일치하는 공연이 있으면 INSERT를 건너뛴다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = (99,) + with patch("db.repository.get_session") as mock_get_session: + mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_get_session.return_value.__exit__ = MagicMock(return_value=False) + duplicates = save_concerts([self._make_concert()]) + + insert_sqls = [ + str(c.args[0]) for c in mock_session.execute.call_args_list + if "INSERT INTO concert" in str(c.args[0]) + ] + assert insert_sqls == [] + assert duplicates == {"PF123456"} + + def test_title_date_check_uses_concert_fields(self): + """중복 검사 쿼리에 title·start_date·end_date 파라미터가 전달되어야 한다.""" + mock_session = MagicMock() + mock_session.execute.return_value.fetchone.return_value = None + with patch("db.repository.get_session") as mock_get_session: + mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) + mock_get_session.return_value.__exit__ = MagicMock(return_value=False) + save_concerts([self._make_concert()]) + + check_call = mock_session.execute.call_args_list[0] + assert check_call.args[1] == { + "title": "공연명", "start_date": "2024-01-01", "end_date": "2024-01-31", + } + def test_concert_image_not_inserted_when_still_urls_empty(self): """still_urls가 빈 리스트이면 concert_image INSERT가 실행되지 않아야 한다.""" mock_session = MagicMock() - mock_session.execute.return_value.fetchone.return_value = (1,) + mock_session.execute.return_value.fetchone.side_effect = [None, (1,)] concert = self._make_concert(still_urls=[]) with patch("db.repository.get_session") as mock_get_session: mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 6d63078..e52b3b1 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -715,6 +715,15 @@ def test_raises_when_db_lookup_fails_after_save(self): ): collect_and_save_concert("PF001") + def test_returns_skipped_when_duplicate_title(self): + """save_concerts가 kopis_id를 중복으로 반환하면 status:skipped를 반환해야 한다.""" + with ( + patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW), + patch("scheduler.save_concerts", return_value={"PF001"}), + ): + result = collect_and_save_concert("PF001") + assert result == {"status": "skipped", "reason": "duplicate_title"} + class TestCollectAndSaveSetlist: _CONCERT = {