[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:
Mina Choi 2026-09-30 15:09:52 +09:00
parent e5153eb66c
commit 87343c791b
9 changed files with 178 additions and 11 deletions

View File

@ -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`.

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 == []

View File

@ -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<J
intervalMs = JOB_POLL_MS,
timeoutMs = JOB_POLL_TIMEOUT_MS,
maxErrors = JOB_MAX_POLL_ERRORS,
unreachableMs = JOB_UNREACHABLE_MS,
onTick,
} = options;
const deadline = Date.now() + timeoutMs;
let errors = 0;
let lastError: unknown;
let lastReachedAt = Date.now();
while (Date.now() < deadline) {
await delay(intervalMs, signal);
@ -74,7 +80,10 @@ export async function pollJob(jobId: string, options: PollJobOptions): Promise<J
// 한 번이라도 잡을 받아오면 카운터를 되돌린다 — 잠깐 끊긴 네트워크로 몇 분짜리 잡을 접으면 안 된다.
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;
onTick?.(job);

View File

@ -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 · **사진**. */

View File

@ -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 ?? ''));
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/<slug>/<version>/`. */
@ -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);