Merge remote-tracking branch 'origin/main'

This commit is contained in:
hbyang 2026-09-30 16:52:51 +09:00
commit 7ee6911836
5 changed files with 11 additions and 124 deletions

View File

@ -13,7 +13,7 @@
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` | | `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` | | `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) | | `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) |
| `activity` | 수집 시작·채널 실패·완료 · 사진 분석 · 첫 발행/재발행/빌드/되돌리기 시작·끝(`services/activity_feed.py`) | 없음(매번 보낸다) | | `activity` | 채널 크롤링 실패 · 사진 분석 일부 실패(`services/activity_feed.py`) | 없음(매번 보낸다) |
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건 ★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`. 운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.

View File

@ -1,6 +1,5 @@
"""정적 빌드 + 발행 — BUILD 잡이 하는 일.""" """정적 빌드 + 발행 — BUILD 잡이 하는 일."""
import os import os
import time
import uuid import uuid
from sqlalchemy import select from sqlalchemy import select
@ -26,7 +25,6 @@ from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.post_crud import PostCRUD from crud.post_crud import PostCRUD
from services import ( from services import (
activity_feed,
alert_service, alert_service,
azure_static, azure_static,
indexnow, indexnow,
@ -120,45 +118,6 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
async def run_build(job: dict) -> dict: async def run_build(job: dict) -> dict:
"""BUILD 잡 핸들러.""" """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"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"] owner_user_id = payload["owner_user_id"]
@ -173,7 +132,6 @@ async def _run_build(job: dict) -> dict:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
site = await ensure_site(place_id) site = await ensure_site(place_id)
mode = ("재발행" if site.published_at is not None else "첫 발행") if want_publish else "빌드만"
# 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다. # 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다.
try: try:
@ -240,12 +198,7 @@ async def _run_build(job: dict) -> dict:
raise BuildAborted(f"버전 생성 실패: {add_err.name}") raise BuildAborted(f"버전 생성 실패: {add_err.name}")
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no, 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), "site_version_id": str(version.site_version_id)}
"mode": mode}
await activity_feed.post(
f"🏗 {mode} 시작",
[f"{result['label']} v{version_no}", f"템플릿 {site.template_id or '기본'}"],
)
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다. # 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
if song_result is not None: if song_result is not None:
result["song"] = song_result result["song"] = song_result
@ -314,8 +267,6 @@ async def _run_build(job: dict) -> dict:
# 발행하지 않는다. # 발행하지 않는다.
return await _fail(str(ex)) return await _fail(str(ex))
if report.get("media"):
result["render_media"] = report["media"]
mismatches = list(report.get("mismatches") or []) mismatches = list(report.get("mismatches") or [])
unique_count_raw = report.get("uniqueContentCount") unique_count_raw = report.get("uniqueContentCount")
unique_count = unique_count_raw or 0 unique_count = unique_count_raw or 0
@ -415,7 +366,6 @@ async def _run_build(job: dict) -> dict:
if thumbnail_url: if thumbnail_url:
site.thumbnail_url = thumbnail_url site.thumbnail_url = thumbnail_url
result["published"] = True result["published"] = True
result["url"] = site_payload.publish_url(place, site)
LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 " LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 "
f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)") f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)")
else: else:

View File

@ -458,47 +458,14 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
# 오케스트레이션 # 오케스트레이션
async def run_collect(job: dict) -> dict: async def run_collect(job: dict) -> dict:
"""COLLECT 잡 핸들러.""" """COLLECT 잡 핸들러."""
started = time.monotonic()
with collect_diagnostics.collecting(): with collect_diagnostics.collecting():
try: result = await _run_collect(job)
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
issues = collect_diagnostics.snapshot() issues = collect_diagnostics.snapshot()
if issues: if issues:
result["issues"] = issues result["issues"] = issues
await _post_collect_summary(result, issues, time.monotonic() - started)
return result 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: async def _run_collect(job: dict) -> dict:
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] 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 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: if not targets:
result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)" result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)"
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)

View File

@ -1,6 +1,5 @@
"""대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환""" """대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환"""
import os import os
import time
import uuid import uuid
from common.database.db_session_manager import DB_SESSION_MNG 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 common.utils.gtime import GTime
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import SiteCRUD from crud.site_crud import SiteCRUD
from services import activity_feed, alert_service, azure_static, indexnow, publish_gate, render_service, site_payload from services import 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.build_service import ensure_site, load_channel_links
_site_crud = SiteCRUD() _site_crud = SiteCRUD()
_place_crud = PlaceCRUD() _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: async def run_rollback(job: dict) -> dict:
"""ROLLBACK 잡 핸들러.""" """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"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"] owner_user_id = payload["owner_user_id"]
@ -96,9 +76,7 @@ async def _run_rollback(job: dict) -> dict:
result = { result = {
"place_id": place_id, "site_id": str(site.site_id), "place_id": place_id, "site_id": str(site.site_id),
"target_version": target_version, "site_version_id": str(version.site_version_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): 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, 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: except render_service.RenderFailed as ex:
return await _fail(str(ex)) return await _fail(str(ex))
if report.get("media"):
result["render_media"] = report["media"]
mismatches = list(report.get("mismatches") or []) mismatches = list(report.get("mismatches") or [])
unique_count_raw = report.get("uniqueContentCount") 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}") await alert_service.resolve_alert(f"build_failed:{place_id}", f"롤백 성공 — {place_id} → v{target_version}")
result["rolled_back"] = True result["rolled_back"] = True
result["url"] = site_payload.publish_url(place, site)
LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림") LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림")
return result return result

View File

@ -113,9 +113,10 @@ async def run_vision(job: dict) -> dict:
LOG.i(f"[vision] place={place_id} {stat['analyzed']}장 → 자동반영 {stat['approved']} · " LOG.i(f"[vision] place={place_id} {stat['analyzed']}장 → 자동반영 {stat['approved']} · "
f"확인필요 {stat['needs_review']} · 실패 {stat['failed']} (임계값 {threshold})") f"확인필요 {stat['needs_review']} · 실패 {stat['failed']} (임계값 {threshold})")
await activity_feed.post( if stat["failed"]:
"🖼 사진 분석 완료" if not stat["failed"] else "⚠️ 사진 분석 일부 실패", await activity_feed.post(
[activity_feed.place_label(place), "⚠️ 사진 분석 일부 실패",
f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"], [activity_feed.place_label(place),
) f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"],
)
return stat return stat