Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
7ee6911836
@ -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`.
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -113,8 +113,9 @@ 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})")
|
||||||
|
if stat["failed"]:
|
||||||
await activity_feed.post(
|
await activity_feed.post(
|
||||||
"🖼 사진 분석 완료" if not stat["failed"] else "⚠️ 사진 분석 일부 실패",
|
"⚠️ 사진 분석 일부 실패",
|
||||||
[activity_feed.place_label(place),
|
[activity_feed.place_label(place),
|
||||||
f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"],
|
f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"],
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user