[fix] solution/backend: Teams 는 장애만 — 수집·발행 시작·완료 알림 제거, 빌드 실패 중복 발송 정리
수집 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 <noreply@anthropic.com>
This commit is contained in:
parent
ca0bea77a7
commit
b75c56a0d6
@ -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`.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user