[feat] solution/backend,frontend,site: 수집·사진 분석·발행 진행을 Teams 로 발송 — 수집 중 배포에도 사진 목록 복구
컨테이너를 재생성하면 docker logs 가 사라져 크롤링이 잘 됐는지 확인할 방법이 없었다. 실측(09-30 버터브루): 수집 도중 배포로 API 가 80초 끊기자 화면이 사진 분석 폴링을 3회 실패 후 포기해 사진 10장이 DB 에 있는데도 0장으로 보였다. - services/activity_feed.py: 기존 alert_outbox·장애 채널로 kind=activity 이벤트 적재(중복 억제 없음) - collect_service: 수집 시작 · 채널 크롤링 실패 즉시 · 완료 요약(채널별·fact·사진·누락 필수항목·소요초) · 실패 - vision_service: 사진 분석 결과 - build_service · rollback_service: 첫 발행/재발행/빌드만/되돌리기 시작·끝 — URL · 굽기 사진 미러링 수 - site/prerender.ts: 렌더 보고서에 사진 미러링 수(media) 추가 - frontend pollJob: 연속 3회 실패여도 3분간 무응답일 때만 unreachable - frontend collectJobs: 사진 분석 폴링을 못 끝내도 사진 목록을 다시 읽고 경고 - test_build_publish: 게이트 반려 검사에서 activity 이벤트는 제외 관련 테스트 17개 파일 295 passed · 2 failed(test_search_console_service — 변경 전에도 실패) frontend tsc·eslint, site tsc·eslint 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e5153eb66c
commit
87343c791b
@ -13,6 +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`) | 없음(매번 보낸다) |
|
||||||
|
|
||||||
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
|
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
|
||||||
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.
|
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""정적 빌드 + 발행 — BUILD 잡이 하는 일."""
|
"""정적 빌드 + 발행 — BUILD 잡이 하는 일."""
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@ -25,6 +26,7 @@ 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,
|
||||||
@ -118,6 +120,45 @@ 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"]
|
||||||
@ -132,6 +173,7 @@ 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:
|
||||||
@ -198,7 +240,12 @@ 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)}
|
"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 '기본'}"],
|
||||||
|
)
|
||||||
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
|
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
|
||||||
if song_result is not None:
|
if song_result is not None:
|
||||||
result["song"] = song_result
|
result["song"] = song_result
|
||||||
@ -267,6 +314,8 @@ 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
|
||||||
@ -366,6 +415,7 @@ 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:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""채널 발견 → 확정 URL 크롤링 → fact·사진 후보 저장."""
|
"""채널 발견 → 확정 URL 크롤링 → fact·사진 후보 저장."""
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from common import collect_diagnostics
|
from common import collect_diagnostics
|
||||||
@ -12,6 +13,7 @@ from common.utils.gtime import GTime
|
|||||||
from crud.fact_crud import FactCRUD
|
from crud.fact_crud import FactCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from common.category_schema import get_schema
|
from common.category_schema import get_schema
|
||||||
|
from services import activity_feed
|
||||||
from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY
|
from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY
|
||||||
from services.collector import yanolja_adapter
|
from services.collector import yanolja_adapter
|
||||||
from services.external import naver_place_lookup, perplexity, tour_lookup
|
from services.external import naver_place_lookup, perplexity, tour_lookup
|
||||||
@ -456,14 +458,47 @@ 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():
|
||||||
result = await _run_collect(job)
|
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
|
||||||
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"]
|
||||||
@ -480,7 +515,7 @@ async def _run_collect(job: dict) -> dict:
|
|||||||
if place.verified_at is None:
|
if place.verified_at is None:
|
||||||
raise CollectAborted("동일 업소 검증(verify) 전에는 수집하지 않는다 — 남의 가게가 섞인다")
|
raise CollectAborted("동일 업소 검증(verify) 전에는 수집하지 않는다 — 남의 가게가 섞인다")
|
||||||
|
|
||||||
result: dict = {"place_id": place_id}
|
result: dict = {"place_id": place_id, "label": activity_feed.place_label(place)}
|
||||||
result["discover"] = await discover_links(
|
result["discover"] = await discover_links(
|
||||||
place,
|
place,
|
||||||
place_id,
|
place_id,
|
||||||
@ -493,6 +528,12 @@ 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)
|
||||||
@ -521,6 +562,8 @@ async def _run_collect(job: dict) -> dict:
|
|||||||
facts_stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}}
|
facts_stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}}
|
||||||
media_stat = {"stored": 0, "skipped_duplicate": 0}
|
media_stat = {"stored": 0, "skipped_duplicate": 0}
|
||||||
unit_total = 0
|
unit_total = 0
|
||||||
|
channel_rows: list[str] = []
|
||||||
|
result["channels"] = channel_rows
|
||||||
|
|
||||||
for index, link in enumerate(targets):
|
for index, link in enumerate(targets):
|
||||||
if index > 0:
|
if index > 0:
|
||||||
@ -530,11 +573,23 @@ async def _run_collect(job: dict) -> dict:
|
|||||||
fetch_stat["stopped_early"] = True
|
fetch_stat["stopped_early"] = True
|
||||||
LOG.i(f"[collect] 필수 항목 {cov['covered']}/{cov['total']} 확보 — "
|
LOG.i(f"[collect] 필수 항목 {cov['covered']}/{cov['total']} 확보 — "
|
||||||
f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략")
|
f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략")
|
||||||
|
channel_rows.append(f"필수 항목 확보 — 남은 {fetch_stat['skipped_enough']}건 생략")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
name = activity_feed.channel_name(link.channel)
|
||||||
|
fetch_started = time.monotonic()
|
||||||
|
issues_before = len(collect_diagnostics.snapshot())
|
||||||
source, outcome = await fetch_one(link, PlaceCategory(place.category))
|
source, outcome = await fetch_one(link, PlaceCategory(place.category))
|
||||||
fetch_stat[outcome] += 1
|
fetch_stat[outcome] += 1
|
||||||
if source is None:
|
if source is None:
|
||||||
|
fresh = collect_diagnostics.snapshot()[issues_before:]
|
||||||
|
reason = f"{fresh[-1]['error_type']}: {fresh[-1]['message'][:300]}" if fresh else outcome
|
||||||
|
channel_rows.append(f"❌ {name} — {reason}")
|
||||||
|
if outcome == "failed":
|
||||||
|
await activity_feed.post(
|
||||||
|
"⚠️ 채널 크롤링 실패",
|
||||||
|
[result["label"], f"{name} {link.url}", reason],
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 수집 원문을 링크에 남긴다 — fact 가 아니라 '생성 근거' 자리다.
|
# 수집 원문을 링크에 남긴다 — fact 가 아니라 '생성 근거' 자리다.
|
||||||
@ -558,6 +613,9 @@ async def _run_collect(job: dict) -> dict:
|
|||||||
facts_stat["by_reason"][reason] = facts_stat["by_reason"].get(reason, 0) + n
|
facts_stat["by_reason"][reason] = facts_stat["by_reason"].get(reason, 0) + n
|
||||||
media_stat["stored"] += m["stored"]
|
media_stat["stored"] += m["stored"]
|
||||||
media_stat["skipped_duplicate"] += m["skipped_duplicate"]
|
media_stat["skipped_duplicate"] += m["skipped_duplicate"]
|
||||||
|
channel_rows.append(
|
||||||
|
f"✓ {name} — fact {f['stored']} · 사진 {m['stored']} · {time.monotonic() - fetch_started:.1f}초"
|
||||||
|
)
|
||||||
|
|
||||||
result["fetch"] = fetch_stat
|
result["fetch"] = fetch_stat
|
||||||
result["units"] = unit_total
|
result["units"] = unit_total
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""대상 버전 스냅샷 로드 → 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
|
||||||
@ -17,8 +18,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 alert_service, azure_static, indexnow, publish_gate, render_service, site_payload
|
from services import activity_feed, alert_service, azure_static, indexnow, publish_gate, render_service, site_payload
|
||||||
from services.build_service import ensure_site, load_channel_links
|
from services.build_service import post_build_summary, ensure_site, load_channel_links
|
||||||
|
|
||||||
_site_crud = SiteCRUD()
|
_site_crud = SiteCRUD()
|
||||||
_place_crud = PlaceCRUD()
|
_place_crud = PlaceCRUD()
|
||||||
@ -45,6 +46,25 @@ 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"]
|
||||||
@ -76,7 +96,9 @@ 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,
|
||||||
@ -107,6 +129,8 @@ 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")
|
||||||
|
|
||||||
@ -141,5 +165,6 @@ 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
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from common.utils.gtime import GTime
|
|||||||
from config.server_configs import external_api_config
|
from config.server_configs import external_api_config
|
||||||
from crud.media_crud import MediaCRUD
|
from crud.media_crud import MediaCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
|
from services import activity_feed
|
||||||
from services.external import gemini
|
from services.external import gemini
|
||||||
from services.llm import provider
|
from services.llm import provider
|
||||||
from common.job_errors import PermanentJobError
|
from common.job_errors import PermanentJobError
|
||||||
@ -112,4 +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})")
|
||||||
|
await activity_feed.post(
|
||||||
|
"🖼 사진 분석 완료" if not stat["failed"] else "⚠️ 사진 분석 일부 실패",
|
||||||
|
[activity_feed.place_label(place),
|
||||||
|
f"분석 {stat['analyzed']} · 승인 {stat['approved']} · 확인대기 {stat['needs_review']} · 실패 {stat['failed']}"],
|
||||||
|
)
|
||||||
return stat
|
return stat
|
||||||
|
|||||||
@ -309,7 +309,7 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다(
|
|||||||
|
|
||||||
# 게이트 반려는 알리지 않는다 — 사장님 쪽 문제를 운영자에게 알리면 안 된다.
|
# 게이트 반려는 알리지 않는다 — 사장님 쪽 문제를 운영자에게 알리면 안 된다.
|
||||||
async with db_engine.begin() as c:
|
async with db_engine.begin() as c:
|
||||||
rows = (await c.execute(text("SELECT kind FROM alert_outbox"))).all()
|
rows = (await c.execute(text("SELECT kind FROM alert_outbox WHERE kind <> 'activity'"))).all()
|
||||||
assert rows == []
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,9 @@ export const JOB_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
|||||||
/** 연속 실패 허용치. */
|
/** 연속 실패 허용치. */
|
||||||
export const JOB_MAX_POLL_ERRORS = 3;
|
export const JOB_MAX_POLL_ERRORS = 3;
|
||||||
|
|
||||||
|
/** 응답이 한 번도 없을 때 포기하기까지의 시간. */
|
||||||
|
export const JOB_UNREACHABLE_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
/** 폴링이 끝난 이유. */
|
/** 폴링이 끝난 이유. */
|
||||||
export type JobOutcome =
|
export type JobOutcome =
|
||||||
| {kind: 'done'; job: JobData}
|
| {kind: 'done'; job: JobData}
|
||||||
@ -26,6 +29,7 @@ export interface PollJobOptions {
|
|||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
maxErrors?: number;
|
maxErrors?: number;
|
||||||
|
unreachableMs?: number;
|
||||||
/** 폴링 1회마다 호출. */
|
/** 폴링 1회마다 호출. */
|
||||||
onTick?: (job: JobData) => void;
|
onTick?: (job: JobData) => void;
|
||||||
}
|
}
|
||||||
@ -52,12 +56,14 @@ export async function pollJob(jobId: string, options: PollJobOptions): Promise<J
|
|||||||
intervalMs = JOB_POLL_MS,
|
intervalMs = JOB_POLL_MS,
|
||||||
timeoutMs = JOB_POLL_TIMEOUT_MS,
|
timeoutMs = JOB_POLL_TIMEOUT_MS,
|
||||||
maxErrors = JOB_MAX_POLL_ERRORS,
|
maxErrors = JOB_MAX_POLL_ERRORS,
|
||||||
|
unreachableMs = JOB_UNREACHABLE_MS,
|
||||||
onTick,
|
onTick,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
let lastReachedAt = Date.now();
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await delay(intervalMs, signal);
|
await delay(intervalMs, signal);
|
||||||
@ -74,7 +80,10 @@ export async function pollJob(jobId: string, options: PollJobOptions): Promise<J
|
|||||||
|
|
||||||
// 한 번이라도 잡을 받아오면 카운터를 되돌린다 — 잠깐 끊긴 네트워크로 몇 분짜리 잡을 접으면 안 된다.
|
// 한 번이라도 잡을 받아오면 카운터를 되돌린다 — 잠깐 끊긴 네트워크로 몇 분짜리 잡을 접으면 안 된다.
|
||||||
errors = job ? 0 : errors + 1;
|
errors = job ? 0 : errors + 1;
|
||||||
if (errors >= maxErrors) return {kind: 'unreachable', error: lastError};
|
if (job) lastReachedAt = Date.now();
|
||||||
|
if (errors >= maxErrors && Date.now() - lastReachedAt >= unreachableMs) {
|
||||||
|
return {kind: 'unreachable', error: lastError};
|
||||||
|
}
|
||||||
if (!job) continue;
|
if (!job) continue;
|
||||||
|
|
||||||
onTick?.(job);
|
onTick?.(job);
|
||||||
|
|||||||
@ -79,7 +79,11 @@ export async function waitForVision(placeId: string, job: JobData, signal: Abort
|
|||||||
const visionJobId = (job.result as {vision_job_id?: string} | undefined)?.vision_job_id;
|
const visionJobId = (job.result as {vision_job_id?: string} | undefined)?.vision_job_id;
|
||||||
if (!visionJobId) return;
|
if (!visionJobId) return;
|
||||||
const outcome = await pollJob(visionJobId, {signal});
|
const outcome = await pollJob(visionJobId, {signal});
|
||||||
if (outcome.kind === 'done') await refreshPlaceCaches(placeId);
|
if (outcome.kind === 'aborted') return;
|
||||||
|
await refreshPlaceCaches(placeId);
|
||||||
|
if (outcome.kind !== 'done') {
|
||||||
|
notify.warn('사진 분석 결과를 아직 받지 못했습니다.', '잠시 후 새로고침하면 사진이 보입니다.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 수집이 끝난 뒤 서버가 진실인 것들 — place(주소·갱신시각) · 링크 · fact · **사진**. */
|
/** 수집이 끝난 뒤 서버가 진실인 것들 — place(주소·갱신시각) · 링크 · fact · **사진**. */
|
||||||
|
|||||||
@ -255,9 +255,17 @@ const MEDIA_EXT: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** 사진을 **우리 오리진으로 옮긴다.** */
|
/** 사진을 **우리 오리진으로 옮긴다.** */
|
||||||
async function mirrorMedia(payload: SitePayload, siteDir: string) {
|
interface MediaMirrorStats {
|
||||||
|
total: number;
|
||||||
|
fetched: number;
|
||||||
|
cached: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mirrorMedia(payload: SitePayload, siteDir: string): Promise<MediaMirrorStats> {
|
||||||
const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? ''));
|
const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? ''));
|
||||||
if (items.length === 0) return;
|
const stats: MediaMirrorStats = {total: items.length, fetched: 0, cached: 0, failed: 0};
|
||||||
|
if (items.length === 0) return stats;
|
||||||
|
|
||||||
const dir = join(siteDir, MEDIA_DIR);
|
const dir = join(siteDir, MEDIA_DIR);
|
||||||
// 절대 주소로 바꾼다.
|
// 절대 주소로 바꾼다.
|
||||||
@ -275,6 +283,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) {
|
|||||||
if (hit) {
|
if (hit) {
|
||||||
wanted.add(hit);
|
wanted.add(hit);
|
||||||
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${hit}`);
|
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${hit}`);
|
||||||
|
stats.cached += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,6 +306,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) {
|
|||||||
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${name}`);
|
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${name}`);
|
||||||
fetched += 1;
|
fetched += 1;
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
stats.failed += 1;
|
||||||
console.warn(` ! 사진을 못 받았습니다(원래 주소를 씁니다): ${origin} — ${ex}`);
|
console.warn(` ! 사진을 못 받았습니다(원래 주소를 씁니다): ${origin} — ${ex}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,6 +316,8 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) {
|
|||||||
if (!wanted.has(name)) rmSync(join(dir, name), {force: true});
|
if (!wanted.has(name)) rmSync(join(dir, name), {force: true});
|
||||||
}
|
}
|
||||||
if (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`);
|
if (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`);
|
||||||
|
stats.fetched = fetched;
|
||||||
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 이 슬러그·버전이 실제로 구워지는 자리 — `out/versions/<slug>/<version>/`. */
|
/** 이 슬러그·버전이 실제로 구워지는 자리 — `out/versions/<slug>/<version>/`. */
|
||||||
@ -573,6 +585,7 @@ interface RenderReport {
|
|||||||
/** 절대규칙 3 위반 목록. */
|
/** 절대규칙 3 위반 목록. */
|
||||||
mismatches: string[];
|
mismatches: string[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
media?: MediaMirrorStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeReport(payloadFile: string, report: RenderReport) {
|
function writeReport(payloadFile: string, report: RenderReport) {
|
||||||
@ -922,7 +935,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await mirrorMedia(entry.payload, stagingDir);
|
const media = await mirrorMedia(entry.payload, stagingDir);
|
||||||
const result = prerenderSite(entry.payload, args.out, assets, referenced);
|
const result = prerenderSite(entry.payload, args.out, assets, referenced);
|
||||||
const payload = result.payload;
|
const payload = result.payload;
|
||||||
origin = origin || payload.site.origin;
|
origin = origin || payload.site.origin;
|
||||||
@ -955,6 +968,7 @@ async function main() {
|
|||||||
jsonld: result.jsonld,
|
jsonld: result.jsonld,
|
||||||
mismatches: [],
|
mismatches: [],
|
||||||
error: null,
|
error: null,
|
||||||
|
media,
|
||||||
};
|
};
|
||||||
writeFileSync(savedReportPath, JSON.stringify({...report, origin: payload.site.origin}));
|
writeFileSync(savedReportPath, JSON.stringify({...report, origin: payload.site.origin}));
|
||||||
writeReport(entry.file, report);
|
writeReport(entry.file, report);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user