From b75c56a0d61f35171ae2e1b6c616407ed66b75cb Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 30 Sep 2026 16:37:24 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/backend:=20Teams=20=EB=8A=94?= =?UTF-8?q?=20=EC=9E=A5=EC=95=A0=EB=A7=8C=20=E2=80=94=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=C2=B7=EB=B0=9C=ED=96=89=20=EC=8B=9C=EC=9E=91=C2=B7=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=95=8C=EB=A6=BC=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수집 1회에 3~4장, 발행 1회에 2장씩 와서 장애 알림이 묻혔다. 빌드·되돌리기 실패와 수집 예외는 기존 build_failed · job_dead 알림이 이미 보내고 있어 같은 사고가 두 번씩 갔다. - collect_service: 시작·완료 요약·예외 알림 제거 — 채널 크롤링 실패만 남긴다(채널별 결과는 잡 결과 channels 에 그대로) - vision_service: 실패가 있을 때만 - build_service · rollback_service: activity 알림 되돌림 — build_failed · recovery 가 그대로 담당 - docs/ALERTS.md: activity 행을 실제 발송 범위로 관련 테스트 17개 파일 297 passed · 2 failed(test_search_console_service — 변경 전에도 실패) Co-Authored-By: Claude Opus 5.5 --- docs/ALERTS.md | 2 +- solution/backend/services/build_service.py | 52 +------------------ solution/backend/services/collect_service.py | 41 +-------------- solution/backend/services/rollback_service.py | 29 +---------- solution/backend/services/vision_service.py | 11 ++-- 5 files changed, 11 insertions(+), 124 deletions(-) diff --git a/docs/ALERTS.md b/docs/ALERTS.md index 11aa227..f062796 100644 --- a/docs/ALERTS.md +++ b/docs/ALERTS.md @@ -13,7 +13,7 @@ | `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` | | `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` | | `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) | -| `activity` | 수집 시작·채널 실패·완료 · 사진 분석 · 첫 발행/재발행/빌드/되돌리기 시작·끝(`services/activity_feed.py`) | 없음(매번 보낸다) | +| `activity` | 채널 크롤링 실패 · 사진 분석 일부 실패(`services/activity_feed.py`) | 없음(매번 보낸다) | ★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건 운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`. diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index e064306..f209d02 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -1,6 +1,5 @@ """정적 빌드 + 발행 — BUILD 잡이 하는 일.""" import os -import time import uuid from sqlalchemy import select @@ -26,7 +25,6 @@ from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD from crud.post_crud import PostCRUD from services import ( - activity_feed, alert_service, azure_static, indexnow, @@ -120,45 +118,6 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult async def run_build(job: dict) -> dict: """BUILD 잡 핸들러.""" - started = time.monotonic() - try: - result = await _run_build(job) - except Exception as ex: - await activity_feed.post( - "❌ 빌드 실패", - [f"place_id={job['payload'].get('place_id')}", f"{type(ex).__name__}: {ex}", - f"{time.monotonic() - started:.0f}초"], - ) - raise - await post_build_summary(result, time.monotonic() - started) - return result - - -async def post_build_summary(result: dict, elapsed: float) -> None: - version = f"v{result.get('version')}" - mode = result.get("mode") or "빌드" - lines = [f"{result.get('label') or result.get('place_id')} {version}"] - if result.get("build_status") == "BUILT": - title = f"✅ {mode} 완료" - if result.get("url"): - lines.append(result["url"]) - elif result.get("gate", {}).get("passed") is False: - title = f"🚫 {mode} 반려(게이트)" - lines.append(str(result.get("error") or "")) - else: - title = f"❌ {mode} 실패" - lines.append(str(result.get("error") or "")) - media = result.get("render_media") - if media: - lines.append( - f"굽기 — 사진 {media.get('total', 0)}장: 새로 받음 {media.get('fetched', 0)} · " - f"기존 {media.get('cached', 0)} · 실패 {media.get('failed', 0)}" - ) - lines.append(f"{elapsed:.0f}초") - await activity_feed.post(title, lines) - - -async def _run_build(job: dict) -> dict: payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] @@ -173,7 +132,6 @@ async def _run_build(job: dict) -> dict: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") site = await ensure_site(place_id) - mode = ("재발행" if site.published_at is not None else "첫 발행") if want_publish else "빌드만" # 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다. try: @@ -240,12 +198,7 @@ async def _run_build(job: dict) -> dict: raise BuildAborted(f"버전 생성 실패: {add_err.name}") result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no, - "site_version_id": str(version.site_version_id), "label": activity_feed.place_label(place), - "mode": mode} - await activity_feed.post( - f"🏗 {mode} 시작", - [f"{result['label']} v{version_no}", f"템플릿 {site.template_id or '기본'}"], - ) + "site_version_id": str(version.site_version_id)} # 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다. if song_result is not None: result["song"] = song_result @@ -314,8 +267,6 @@ async def _run_build(job: dict) -> dict: # 발행하지 않는다. return await _fail(str(ex)) - if report.get("media"): - result["render_media"] = report["media"] mismatches = list(report.get("mismatches") or []) unique_count_raw = report.get("uniqueContentCount") unique_count = unique_count_raw or 0 @@ -415,7 +366,6 @@ async def _run_build(job: dict) -> dict: if thumbnail_url: site.thumbnail_url = thumbnail_url result["published"] = True - result["url"] = site_payload.publish_url(place, site) LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 " f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)") else: diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 38d8705..a4f7b76 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -458,47 +458,14 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict: # 오케스트레이션 async def run_collect(job: dict) -> dict: """COLLECT 잡 핸들러.""" - started = time.monotonic() with collect_diagnostics.collecting(): - try: - result = await _run_collect(job) - except Exception as ex: - await activity_feed.post( - "❌ 수집 실패", - [f"place_id={job['payload'].get('place_id')}", f"{type(ex).__name__}: {ex}", - f"{time.monotonic() - started:.0f}초"], - ) - raise + result = await _run_collect(job) issues = collect_diagnostics.snapshot() if issues: result["issues"] = issues - await _post_collect_summary(result, issues, time.monotonic() - started) return result -async def _post_collect_summary(result: dict, issues: list[dict], elapsed: float) -> None: - lines = [result.get("label") or f"place_id={result.get('place_id')}"] - if result.get("note"): - lines.append(result["note"]) - for row in result.get("channels") or []: - lines.append(row) - facts = result.get("facts") - if facts: - reasons = ", ".join(f"{k} {v}" for k, v in facts["by_reason"].items()) - lines.append(f"fact 저장 {facts['stored']} · 반려 {facts['rejected']}" + (f" ({reasons})" if reasons else "")) - media = result.get("media") - if media: - lines.append(f"사진 {media['stored']}장 (중복 {media['skipped_duplicate']})") - cov = result.get("coverage") - if cov and cov["missing"]: - lines.append(f"필수 항목 {cov['covered']}/{cov['total']} — 누락 {', '.join(cov['missing'])}") - other = [i for i in issues if i["stage"] != "fetch"] - for issue in other: - lines.append(f"⚠️ {issue['stage']} 실패: {issue['error_type']}: {issue['message'][:200]}") - lines.append(f"{elapsed:.0f}초") - await activity_feed.post("✅ 수집 완료", lines) - - async def _run_collect(job: dict) -> dict: payload = job["payload"] place_id = payload["place_id"] @@ -528,12 +495,6 @@ async def _run_collect(job: dict) -> dict: link.channel == LinkChannel.NAVER_PLACE.value for link in targets ) - await activity_feed.post( - "🔎 수집 시작", - [result["label"], f"place_id={place_id}", - f"크롤링 대상 {len(targets)}건: " + (", ".join(activity_feed.channel_name(l.channel) for l in targets) or "없음")], - ) - if not targets: result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)" await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) diff --git a/solution/backend/services/rollback_service.py b/solution/backend/services/rollback_service.py index b2bced9..c2553c5 100644 --- a/solution/backend/services/rollback_service.py +++ b/solution/backend/services/rollback_service.py @@ -1,6 +1,5 @@ """대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환""" import os -import time import uuid from common.database.db_session_manager import DB_SESSION_MNG @@ -18,8 +17,8 @@ from common.logger import LOG from common.utils.gtime import GTime from crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD -from services import activity_feed, alert_service, azure_static, indexnow, publish_gate, render_service, site_payload -from services.build_service import post_build_summary, ensure_site, load_channel_links +from services import alert_service, azure_static, indexnow, publish_gate, render_service, site_payload +from services.build_service import ensure_site, load_channel_links _site_crud = SiteCRUD() _place_crud = PlaceCRUD() @@ -46,25 +45,6 @@ async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None async def run_rollback(job: dict) -> dict: """ROLLBACK 잡 핸들러.""" - started = time.monotonic() - try: - result = await _run_rollback(job) - except Exception as ex: - await activity_feed.post( - "❌ 되돌리기 실패", - [f"place_id={job['payload'].get('place_id')} → v{job['payload'].get('target_version')}", - f"{type(ex).__name__}: {ex}", f"{time.monotonic() - started:.0f}초"], - ) - raise - await post_build_summary( - {**result, "version": result.get("target_version"), "mode": "되돌리기", - "build_status": "BUILT" if result.get("rolled_back") else "FAILED"}, - time.monotonic() - started, - ) - return result - - -async def _run_rollback(job: dict) -> dict: payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] @@ -96,9 +76,7 @@ async def _run_rollback(job: dict) -> dict: result = { "place_id": place_id, "site_id": str(site.site_id), "target_version": target_version, "site_version_id": str(version.site_version_id), - "label": activity_feed.place_label(place), } - await activity_feed.post("🏗 되돌리기 시작", [f"{result['label']} → v{target_version}"]) async def _fail(reason: str, gate: publish_gate.GateResult | None = None): await _log(site.site_id, version.site_version_id, PublishResult.REJECTED if gate else PublishResult.FAILED, @@ -129,8 +107,6 @@ async def _run_rollback(job: dict) -> dict: except render_service.RenderFailed as ex: return await _fail(str(ex)) - if report.get("media"): - result["render_media"] = report["media"] mismatches = list(report.get("mismatches") or []) unique_count_raw = report.get("uniqueContentCount") @@ -165,6 +141,5 @@ async def _run_rollback(job: dict) -> dict: await alert_service.resolve_alert(f"build_failed:{place_id}", f"롤백 성공 — {place_id} → v{target_version}") result["rolled_back"] = True - result["url"] = site_payload.publish_url(place, site) LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림") return result diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 284e868..0eb7b06 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -113,9 +113,10 @@ async def run_vision(job: dict) -> dict: LOG.i(f"[vision] place={place_id} {stat['analyzed']}장 → 자동반영 {stat['approved']} · " f"확인필요 {stat['needs_review']} · 실패 {stat['failed']} (임계값 {threshold})") - await activity_feed.post( - "🖼 사진 분석 완료" if not stat["failed"] else "⚠️ 사진 분석 일부 실패", - [activity_feed.place_label(place), - f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"], - ) + if stat["failed"]: + await activity_feed.post( + "⚠️ 사진 분석 일부 실패", + [activity_feed.place_label(place), + f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"], + ) return stat