o2o-site-AEO/solution/backend/services/build_service.py
hbyang 4650125c6c [fix] solution/backend: 미니블로그 자동 게재가 양쪽 끝에서 끊겨 있었다 — 생성 미등록·승인 후 발행 사망
증상은 둘 다 "메일이 안 온다" / "승인했는데 홈페이지가 그대로" 로만 보인다. 서버는 정상으로
뜨고 로그에도 에러가 없어서 눈으로 원인을 못 찾는 종류다.

① 재고 채우기가 스케줄러에 **등록돼 있지 않았다.** `scheduler/jobs.py` 에 함수는 있고
   `__init__.py` 주석도 "새벽에 재고를 채운다" 라고 말하는데 add_job 한 줄이 없어 한 번도
   돈 적이 없다. 09:00 발송만 돌고 보낼 글은 0건이었다 — 지금 DB 의 254건은 전부 화면의
   [지금 생성하기] 로 손으로 만든 것이다.
   → 04:10 KST 등록(발송보다 앞서야 그날 아침에 나갈 재고가 있다)

② 승인 뒤 재발행이 죽는다. `post_service._enqueue_build` 가 requested_by 에
   "blog-approval" 이라는 **라벨**을 넣었고 `build_service._log` 가 그걸 uuid.UUID() 에
   넣다 ValueError 를 던졌다. 하필 _log 는 사이트를 다 구운 **뒤**, sites.status 를
   PUBLISHED 로 찍기 **전**에 불린다 — 굽기는 끝났는데 발행만 안 된 채 3회 재시도 후 DEAD.
   실측: BUILD 잡 5건(2026-09-23~09-30)이 전부 이 원인이고 전부 미니블로그 승인분이었다.
   → 호출부는 사장님 ID 를 넣고, 파서(_actor_uuid)는 모양이 틀리면 기록만 비우고 진행한다.
     감사 기록 한 줄이 발행을 막는 것은 순서가 뒤집힌 것이다. rollback_service 도 같은 파서.

- scheduler/__init__: blog-drafts 등록 + 왜 빠져 있었는지
- services/build_service: _actor_uuid 신설, _log 가 그것만 쓴다
- services/post_service: requested_by = str(owner_user_id)
- services/rollback_service: 같은 파서 재사용

테스트 7건 추가(build 4 · scheduler 3), 전부 통과.
test_build_publish.py 의 기존 실패 13건은 변동 없음 — 원본으로 되돌려 측정해 확인했다
(원본 13 failed/2 passed, 변경 후 13 failed/6 passed). 그 13건은 컨테이너 테스트 DB
쪽 문제다(로그: lease 갱신 실패 InvalidCatalogNameError) — 이 변경과 무관하고 미해결로 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 17:31:10 +09:00

399 lines
17 KiB
Python

"""정적 빌드 + 발행 — 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":
"""사업장의 사이트 행을 보장한다(없으면 만든다)."""
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 []
def _actor_uuid(actor) -> uuid.UUID | None:
"""`payload["requested_by"]` 를 actor_user_id 로 옮긴다.
★★ **여기서 예외를 올리면 발행이 죽는다.** `_log` 는 사이트를 다 구운 **뒤에**, 그런데
`sites.status = PUBLISHED` 를 찍기 **전에** 불린다 — 던지면 굽기는 끝났는데 발행 상태만
안 바뀌고, 잡은 3회 재시도 끝에 DEAD 가 된다. 사장님 화면에는 "승인했는데 홈페이지가
그대로" 로만 보인다. 실측(2026-09-23~09-30): BUILD 잡 5건이 이렇게 죽었다 —
`post_service` 가 미니블로그 승인 경로에서 `requested_by="blog-approval"` 이라는
**사람이 아닌 라벨**을 넣었고 `uuid.UUID()` 가 ValueError 를 던졌다.
★ 그래서 모양이 틀린 값은 **기록만 비우고 넘어간다.** 감사 기록 한 줄이 발행을 막는 것은
순서가 뒤집힌 것이다."""
if not actor:
return None
try:
return uuid.UUID(str(actor))
except (ValueError, AttributeError, TypeError):
LOG.w(f"[build] requested_by 가 사용자 ID 가 아니다({actor!r}) — actor 없이 기록한다")
return None
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=_actor_uuid(actor),
)
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}")
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")
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}
),
)
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