diff --git a/docs/ALERTS.md b/docs/ALERTS.md index dc25ba0..11aa227 100644 --- a/docs/ALERTS.md +++ b/docs/ALERTS.md @@ -13,6 +13,7 @@ | `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` | | `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` | | `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) | +| `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 f209d02..e064306 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -1,5 +1,6 @@ """정적 빌드 + 발행 — BUILD 잡이 하는 일.""" import os +import time import uuid from sqlalchemy import select @@ -25,6 +26,7 @@ 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, @@ -118,6 +120,45 @@ 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"] @@ -132,6 +173,7 @@ 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: @@ -198,7 +240,12 @@ 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)} + "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: result["song"] = song_result @@ -267,6 +314,8 @@ 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 @@ -366,6 +415,7 @@ 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 8e8d576..38d8705 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -1,5 +1,6 @@ """채널 발견 → 확정 URL 크롤링 → fact·사진 후보 저장.""" import re +import time import uuid from common import collect_diagnostics @@ -12,6 +13,7 @@ from common.utils.gtime import GTime from crud.fact_crud import FactCRUD from crud.place_crud import PlaceCRUD from common.category_schema import get_schema +from services import activity_feed from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY from services.collector import yanolja_adapter 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: """COLLECT 잡 핸들러.""" + started = time.monotonic() 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() 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"] @@ -480,7 +515,7 @@ async def _run_collect(job: dict) -> dict: if place.verified_at is None: 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( place, place_id, @@ -493,6 +528,12 @@ 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) @@ -521,6 +562,8 @@ async def _run_collect(job: dict) -> dict: facts_stat = {"stored": 0, "refreshed": 0, "candidate": 0, "rejected": 0, "by_reason": {}} media_stat = {"stored": 0, "skipped_duplicate": 0} unit_total = 0 + channel_rows: list[str] = [] + result["channels"] = channel_rows for index, link in enumerate(targets): if index > 0: @@ -530,11 +573,23 @@ async def _run_collect(job: dict) -> dict: fetch_stat["stopped_early"] = True LOG.i(f"[collect] 필수 항목 {cov['covered']}/{cov['total']} 확보 — " f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략") + channel_rows.append(f"필수 항목 확보 — 남은 {fetch_stat['skipped_enough']}건 생략") 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)) fetch_stat[outcome] += 1 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 # 수집 원문을 링크에 남긴다 — 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 media_stat["stored"] += m["stored"] 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["units"] = unit_total diff --git a/solution/backend/services/rollback_service.py b/solution/backend/services/rollback_service.py index c2553c5..b2bced9 100644 --- a/solution/backend/services/rollback_service.py +++ b/solution/backend/services/rollback_service.py @@ -1,5 +1,6 @@ """대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환""" import os +import time import uuid 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 crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD -from services import alert_service, azure_static, indexnow, publish_gate, render_service, site_payload -from services.build_service import ensure_site, load_channel_links +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 _site_crud = SiteCRUD() _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: """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"] @@ -76,7 +96,9 @@ 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, @@ -107,6 +129,8 @@ 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") @@ -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}") 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 fb15471..284e868 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -9,6 +9,7 @@ from common.utils.gtime import GTime from config.server_configs import external_api_config from crud.media_crud import MediaCRUD from crud.place_crud import PlaceCRUD +from services import activity_feed from services.external import gemini from services.llm import provider 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']} · " 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 diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index 460ebbf..ba4dafd 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -309,7 +309,7 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다( # 게이트 반려는 알리지 않는다 — 사장님 쪽 문제를 운영자에게 알리면 안 된다. 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 == [] diff --git a/solution/frontend/src/api/pollJob.ts b/solution/frontend/src/api/pollJob.ts index 393fde9..77fae2d 100644 --- a/solution/frontend/src/api/pollJob.ts +++ b/solution/frontend/src/api/pollJob.ts @@ -12,6 +12,9 @@ export const JOB_POLL_TIMEOUT_MS = 10 * 60 * 1000; /** 연속 실패 허용치. */ export const JOB_MAX_POLL_ERRORS = 3; +/** 응답이 한 번도 없을 때 포기하기까지의 시간. */ +export const JOB_UNREACHABLE_MS = 3 * 60 * 1000; + /** 폴링이 끝난 이유. */ export type JobOutcome = | {kind: 'done'; job: JobData} @@ -26,6 +29,7 @@ export interface PollJobOptions { intervalMs?: number; timeoutMs?: number; maxErrors?: number; + unreachableMs?: number; /** 폴링 1회마다 호출. */ onTick?: (job: JobData) => void; } @@ -52,12 +56,14 @@ export async function pollJob(jobId: string, options: PollJobOptions): Promise= 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; onTick?.(job); diff --git a/solution/frontend/src/features/onboarding/collectJobs.ts b/solution/frontend/src/features/onboarding/collectJobs.ts index e0450d5..5e95973 100644 --- a/solution/frontend/src/features/onboarding/collectJobs.ts +++ b/solution/frontend/src/features/onboarding/collectJobs.ts @@ -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; if (!visionJobId) return; 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 · **사진**. */ diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 694841d..7ea7d21 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -255,9 +255,17 @@ const MEDIA_EXT: Record = { }; /** 사진을 **우리 오리진으로 옮긴다.** */ -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 { 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); // 절대 주소로 바꾼다. @@ -275,6 +283,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) { if (hit) { wanted.add(hit); item.url = joinUrl(publicBase, `${MEDIA_DIR}/${hit}`); + stats.cached += 1; continue; } @@ -297,6 +306,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) { item.url = joinUrl(publicBase, `${MEDIA_DIR}/${name}`); fetched += 1; } catch (ex) { + stats.failed += 1; 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 (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`); + stats.fetched = fetched; + return stats; } /** 이 슬러그·버전이 실제로 구워지는 자리 — `out/versions///`. */ @@ -573,6 +585,7 @@ interface RenderReport { /** 절대규칙 3 위반 목록. */ mismatches: string[]; error: string | null; + media?: MediaMirrorStats; } function writeReport(payloadFile: string, report: RenderReport) { @@ -922,7 +935,7 @@ async function main() { } continue; } - await mirrorMedia(entry.payload, stagingDir); + const media = await mirrorMedia(entry.payload, stagingDir); const result = prerenderSite(entry.payload, args.out, assets, referenced); const payload = result.payload; origin = origin || payload.site.origin; @@ -955,6 +968,7 @@ async function main() { jsonld: result.jsonld, mismatches: [], error: null, + media, }; writeFileSync(savedReportPath, JSON.stringify({...report, origin: payload.site.origin})); writeReport(entry.file, report);