"""정적 빌드 + 발행 — BUILD 잡이 하는 일. 스냅샷 조립 → 빌드(HTML + JSON-LD) → 발행 검수 게이트 → site_version 기록 ★ 정적 빌드다. DB 는 여기서만 읽고, 그 결과가 site_versions.snapshot 에 박제된다. 방문자는 DB 와 만나지 않는다. ★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다 — places.content_updated_at 이 바뀐 사업장만 다시 빌드하면 된다. ★ 게이트를 통과하지 못하면 버전은 FAILED 로 남고 발행되지 않는다. 사유가 publish_logs 에 남는다. """ 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_links, places, publish_logs, site_versions, sites from common.enums import ( BuildStatus, DBWRType, ErrorType, PlaceCategory, PlaceStatus, PublishAction, PublishResult, SiteStatus, ) from common.logger import LOG from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail from services.site_payload import emit_payload from services.snapshot import build_snapshot _site_crud = SiteCRUD() _place_crud = PlaceCRUD() # 렌더러가 이 버전을 굽고 보고서를 돌려줄 때까지 기다리는 시간. # ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 대기 초과"로 실패한다 — # 렌더러는 payload 를 2초 주기로 보고, 사이트 하나 굽는 데 1초 남짓 걸린다. RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180) class BuildAborted(RuntimeError): """재시도해도 소용없는 중단 — 잡의 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_links(place_id: str) -> list: """채널 링크(야놀자·네이버 플레이스·인스타…). 스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다. ★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다.""" err, rows = await DB_SESSION_MNG.execute_lambda( place_links.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute( s, select(place_links).where( place_links.place_id == uuid.UUID(place_id), place_links.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 = 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([publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) async def run_build(job: dict) -> dict: """BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?} publish=True 면 게이트를 통과했을 때 바로 발행까지 한다.""" payload = job["payload"] place_id = payload["place_id"] company_id = payload["company_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(company_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") site = await ensure_site(place_id) snapshot = await build_snapshot(place) 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)} 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}") return result # ---- 1차 게이트: 렌더 없이 판정 가능한 것 ---- # ★ 미검증 fact 는 payload 를 쓰기 전에 막는다. 렌더러에 넘긴 뒤에 막으면 검증 안 된 값이 # 디스크에 한 번 나갔다 들어오는 셈이 된다. 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) # ---- 렌더러에 넘긴다 ---- # ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(solution/site)의 유일한 입력이 # 이 payload JSON 이고, 그게 굽는 HTML 이 방문자와 크롤러가 보는 유일한 페이지다. # ★ payload 에 실릴 발행 상태를 미리 맞춘다. 렌더러는 이 값으로 datePublished 를 굽는데, # 발행 뒤에 payload 를 쓰던 예전 순서에서는 그게 채워져 있었다. 순서가 바뀌었다고 # 페이지의 발행일이 비면 AI 검색이 보는 신선도 신호가 사라진다. # ★ 게이트에서 떨어지면 DB 에는 반영하지 않는다(아래에서 실제로 쓸 때만 저장한다). if want_publish: site.status = SiteStatus.PUBLISHED.value site.published_at = site.published_at or now links = await _load_links(place_id) payload_path = emit_payload(place, snapshot, site, version, links) if not payload_path: return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다") result["payload_path"] = payload_path slug = site_payload.publish_slug(place, site) # ---- 렌더 결과를 기다린다 ---- # ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해 # 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다. report = await render_report.wait_for(slug, version_no, RENDER_TIMEOUT_SEC) if report is None: # ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면 # 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다. return await _fail( f"렌더 결과를 {RENDER_TIMEOUT_SEC}초 안에 받지 못했다 — 렌더러가 도는지 확인이 필요하다" ) mismatches = list(report.get("mismatches") or []) # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고, # 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다. 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차 게이트: 렌더 산출물 기준 ---- # ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가 # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다. # 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다. 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"): # ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지 # 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패" # 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다. 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: azure_result = await azure_static.publish(slug) except azure_static.AzurePublishError as ex: return await _fail(str(ex), None, stamp) if azure_result: result["azure"] = azure_result # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). thumbnail_url = await site_thumbnail.store(slug, snapshot) if thumbnail_url: result["thumbnail_url"] = thumbnail_url # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. # 실패해도 발행은 성공이다 — 색인 통보는 부수 효과이고, 다음 발행에서 다시 보낸다. indexnow_result = await indexnow.submit(slug) if indexnow_result: result["indexnow"] = indexnow_result # ---- 빌드 성공 ---- # ★ jsonld·고유콘텐츠 수는 **렌더러가 실제로 내보낸 값**이다. 백엔드가 따로 계산하지 않는다 — # 따로 계산하던 시절엔 게이트가 통과시킨 근거와 실제 페이지가 어긋날 수 있었다. 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") 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), ) # ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라, # 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고 # 자기 사이트가 나갔는지 알 수 없다. 목록은 사이트 행을 읽지 않는다(N+1). await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} ), ) 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