"""정적 빌드 + 발행 — BUILD 잡이 하는 일.""" import os import uuid from sqlalchemy import select from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import ( place_channels, place_posts, place_reviews, places, site_publish_logs, site_versions, sites, ) from common.enums import ( BuildStatus, DBWRType, ErrorType, PlaceCategory, PlaceStatus, PublishAction, PublishResult, SiteStatus, ) from common.logger import LOG from common.template_catalog import UnknownTemplate, resolve_template_id from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD from crud.post_crud import PostCRUD from services import ( alert_service, azure_static, indexnow, publish_gate, render_service, seo_keywords, site_payload, site_thumbnail, song_service, ) from services.local_content_service import LocalContentService from services.site_payload import emit_payload from services.snapshot import build_snapshot from common.job_errors import PermanentJobError _site_crud = SiteCRUD() _place_crud = PlaceCRUD() _post_crud = PostCRUD() async def _stamp_reviews(session, place_id, version_id): from sqlalchemy import update from common.enums import ReviewStatus await session.execute( update(place_reviews) .where( place_reviews.place_id == place_id, place_reviews.status == ReviewStatus.PUBLISHED.value, place_reviews.published_version_id.is_(None), ) .values(published_version_id=version_id) ) return ErrorType.SUCCESS # 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함). RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180) class BuildAborted(PermanentJobError): """재시도해도 소용없는 중단 — 잡의 last_error 로 남는다.""" async def ensure_site(place_id: str) -> "sites": """사업장의 사이트 행을 보장한다(없으면 만든다). 사업장당 1개.""" pid = uuid.UUID(place_id) err, site = await DB_SESSION_MNG.execute_lambda( sites.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.get_site_by_place(s, pid) ) if err == ErrorType.SUCCESS and site is not None: return site row = sites(place_id=pid, status=SiteStatus.DRAFT.value) run_err = await DB_SESSION_MNG.execute_lambda_run( [sites.DBType()], [lambda s: _site_crud.add_site(s, row)] ) if run_err != ErrorType.SUCCESS: raise BuildAborted(f"사이트 생성 실패: {run_err.name}") return row async def load_channel_links(place_id: str) -> list: """채널 링크(야놀자·네이버 플레이스·인스타…).""" err, rows = await DB_SESSION_MNG.execute_lambda( place_channels.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute( s, select(place_channels).where( place_channels.place_id == uuid.UUID(place_id), place_channels.deleted == False, # noqa: E712 ), ), ) return list(rows) if err == ErrorType.SUCCESS else [] async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None): """발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다.""" row = site_publish_logs( site_id=site_id, site_version_id=version_id, action=action.value, result=result.value, reject_reason=(gate.reason.value if gate is not None and gate.reason else None), detail=(gate.as_log() if gate is not None and not gate.passed else None), actor_user_id=uuid.UUID(actor) if actor else None, ) await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) async def run_build(job: dict) -> dict: """BUILD 잡 핸들러.""" payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] want_publish = bool(payload.get("publish")) err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") site = await ensure_site(place_id) # 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다. try: synced = await LocalContentService().sync_place(place) if not synced.result.success: LOG.w(f"[build] place={place_id} 주변정보 갱신 건너뜀(직전 값 사용): {synced.msg}") except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다 LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}") # 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다. song_result: dict | None = None if want_publish: try: song_result = await song_service.ensure_song(place_id, owner_user_id) LOG.i(f"[build] place={place_id} 노래 — {song_result}") except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다 song_result = {"error": f"{type(ex).__name__}: {ex}"} LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}") # 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도 아니다 — 별도 종류(partial_failure)로 알린다. await alert_service.send_alert( kind="partial_failure", title=f"노래 생성 실패(발행은 계속) — {place_id}", detail=f"place_id={place_id}\n{song_result['error']}", dedupe_key=f"song_failed:{place_id}", ) # 일정(LLM)은 **여기서 직접** 부른다. try: from services.itinerary_llm_service import ensure_generated made = await ensure_generated(place) if made["counts"]: LOG.i(f"[build] place={place_id} 일정 생성 {made['counts']}") except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다 LOG.w(f"[build] place={place_id} 일정 생성 실패(직전 값 사용): {type(ex).__name__}: {ex}") snapshot = await build_snapshot(place) # 메타 태그용 검색 키워드(SiteOntology). seo: dict | None = None try: seo = await seo_keywords.fetch(place_id, snapshot) except Exception as ex: # noqa: BLE001 — 키워드 실패가 발행을 죽이면 안 된다 LOG.w(f"[build] place={place_id} 검색 키워드 실패(키워드 없이 발행): {type(ex).__name__}: {ex}") if seo: snapshot["seo"] = seo v_err, version_no = await DB_SESSION_MNG.execute_lambda( site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id) ) if v_err != ErrorType.SUCCESS: raise BuildAborted(f"버전 번호 조회 실패: {v_err.name}") version = site_versions( site_id=site.site_id, version=version_no, build_status=BuildStatus.BUILDING.value, snapshot=snapshot, ) add_err = await DB_SESSION_MNG.execute_lambda_run( [site_versions.DBType()], [lambda s: _site_crud.add_version(s, version)] ) if add_err != ErrorType.SUCCESS: 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)} # 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다. if song_result is not None: result["song"] = song_result if seo is not None: result["seo"] = seo now = GTime.UTC() async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None): """버전을 FAILED 로 남기고 사유를 기록한다. 발행하지 않는다.""" await DB_SESSION_MNG.execute_lambda_claim( site_versions.DBType(), lambda s: _site_crud.finish_version( s, version.site_version_id, {"build_status": BuildStatus.FAILED.value, "build_error": reason[:2000], **(extra or {})}, ), ) action = PublishAction.PUBLISH if gate is not None else PublishAction.REBUILD outcome = PublishResult.REJECTED if gate is not None else PublishResult.FAILED await _log(site.site_id, version.site_version_id, action, outcome, gate, payload.get("requested_by")) result["build_status"] = "FAILED" result["error"] = reason LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}") # 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고 운영자를 부르면 안 된다. if gate is None: await alert_service.send_alert( kind="build_failed", title=f"발행 실패 — {place_name or place_id}", detail=f"place_id={place_id} v{version_no}\n{reason}", dedupe_key=f"build_failed:{place_id}", ) return result # ---- 1차 게이트: 렌더 없이 판정 가능한 것 ---- place_name = str((snapshot.get("place") or {}).get("name") or "").strip() if not place_name: return await _fail("상호명이 없다 — 사이트를 만들 수 없다") if (snapshot.get("place") or {}).get("category") is None: return await _fail("업종이 없다 — 어떤 스키마로 렌더할지 알 수 없다") facts_gate = publish_gate.check_facts_verified(snapshot["facts"]) if not facts_gate.passed: result["gate"] = {"passed": False, **facts_gate.as_log()} return await _fail(f"{facts_gate.reason.name}: {facts_gate.as_log()}", facts_gate) try: resolve_template_id(int(snapshot["place"]["category"]), site.template_id) except UnknownTemplate as ex: return await _fail(str(ex)) # ---- 렌더러에 넘긴다 ---- if want_publish: site.status = SiteStatus.PUBLISHED.value site.published_at = site.published_at or now links = await load_channel_links(place_id) payload_path = await emit_payload(place, snapshot, site, version, links, publish=want_publish) if not payload_path: return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다") result["payload_path"] = payload_path slug = site_payload.publish_slug(place, site) # ---- 렌더러를 직접 돌린다 ---- try: report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC) except render_service.RenderFailed as ex: # 발행하지 않는다. return await _fail(str(ex)) mismatches = list(report.get("mismatches") or []) unique_count_raw = report.get("uniqueContentCount") unique_count = unique_count_raw or 0 jsonld = report.get("jsonld") or [] result["unique_content_count"] = unique_count result["mismatches"] = mismatches[:20] stamp = {"jsonld": jsonld, "unique_content_count": unique_count} # ---- 2차 게이트: 렌더 산출물 기준 ---- gate = publish_gate.evaluate( PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches ) result["gate"] = {"passed": gate.passed, **gate.as_log()} if not gate.passed: return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp) if not report.get("ok"): # 렌더러가 거부한 이유에 사유 코드를 붙인다. thin = publish_gate.check_unique_content(unique_count_raw) if not thin.passed: # 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다. result["gate"] = {"passed": False, **thin.as_log()} return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp) # 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). return await _fail(str(report.get("error") or "렌더 실패"), None, stamp) # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. thumbnail_url = None if want_publish: try: await render_service.activate_site(slug, version_no) azure_result = await azure_static.publish(slug) except (azure_static.AzurePublishError, render_service.RenderFailed) as ex: return await _fail(str(ex), None, stamp) if azure_result: result["azure"] = azure_result # 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no) if thumbnail_url: result["thumbnail_url"] = thumbnail_url # 정적 파일이 올라간 **뒤에** 통보한다. indexnow_result = await indexnow.submit(slug) if indexnow_result: result["indexnow"] = indexnow_result # ---- 빌드 성공 ---- await DB_SESSION_MNG.execute_lambda_claim( site_versions.DBType(), lambda s: _site_crud.finish_version( s, version.site_version_id, { "build_status": BuildStatus.BUILT.value, "jsonld": jsonld, "unique_content_count": unique_count, "built_at": now, "build_error": None, }, ), ) result["build_status"] = "BUILT" result["routes"] = report.get("routes") # 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). await alert_service.resolve_alert(f"build_failed:{place_id}", f"발행 재개 — {place_name or place_id}") if want_publish: # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다). site_update = { "status": SiteStatus.PUBLISHED.value, "current_version_id": version.site_version_id, "published_at": now, **({"thumbnail_url": thumbnail_url} if thumbnail_url else {}), } await DB_SESSION_MNG.execute_lambda_claim( sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update), ) # 사업장 상태도 같이 올린다. await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} ), ) # 승인된 미니 블로그 글은 이 굽기에 실렸다 — 이제 게재로 넘긴다(docs/MINI_BLOG.md). await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s: _post_crud.mark_published(s, uuid.UUID(place_id), version.site_version_id)], ) # 검수를 통과한 후기도 이 버전에 실렸다 — 어느 굽기에 들어갔는지 남긴다. await DB_SESSION_MNG.execute_lambda_run( [place_reviews.DBType()], [lambda s: _stamp_reviews(s, uuid.UUID(place_id), version.site_version_id)], ) await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None, payload.get("requested_by")) site.status = SiteStatus.PUBLISHED.value site.current_version_id = version.site_version_id site.published_at = now if thumbnail_url: site.thumbnail_url = thumbnail_url result["published"] = True LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 " f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)") else: await _log(site.site_id, version.site_version_id, PublishAction.REBUILD, PublishResult.SUCCESS, None, payload.get("requested_by")) LOG.i(f"[build] place={place_id} v{version_no} 빌드 완료(미발행)") version.build_status = BuildStatus.BUILT.value version.built_at = now return result