o2o-site-AEO/solution/backend/services/rollback_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

148 lines
6.3 KiB
Python

"""대상 버전 스냅샷 로드 → payload 재조립 → 렌더 → 게이트 → 공개 주소 전환"""
import os
import uuid
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, site_publish_logs, site_versions, sites
from common.enums import (
BuildStatus,
DBWRType,
ErrorType,
PlaceCategory,
PublishAction,
PublishResult,
SiteStatus,
)
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 _actor_uuid, ensure_site, load_channel_links
_site_crud = SiteCRUD()
_place_crud = PlaceCRUD()
RENDER_TIMEOUT_SEC_ENV = "RENDER_TIMEOUT_SEC"
class RollbackAborted(RuntimeError):
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None):
row = site_publish_logs(
site_id=site_id,
site_version_id=version_id,
action=PublishAction.ROLLBACK.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),
# ★ build_service 와 같은 함수를 쓴다 — 모양이 틀린 actor 하나가 되돌리기를
# 죽이면 안 된다(그쪽 주석에 실측이 있다).
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_rollback(job: dict) -> dict:
"""ROLLBACK 잡 핸들러."""
payload = job["payload"]
place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"]
target_version = int(payload["target_version"])
render_timeout_sec = float(os.environ.get(RENDER_TIMEOUT_SEC_ENV) or 180)
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 RollbackAborted(f"사업장을 찾을 수 없다: {place_id}")
site = await ensure_site(place_id)
v_err, version = await DB_SESSION_MNG.execute_lambda(
site_versions.DBType(),
DBWRType.DB_READ.value,
lambda s: _site_crud.get_version_by_number(s, site.site_id, target_version),
)
if v_err != ErrorType.SUCCESS or version is None:
raise RollbackAborted(f"버전 {target_version} 을 찾을 수 없다")
if version.build_status != BuildStatus.BUILT.value or not version.snapshot:
raise RollbackAborted(f"버전 {target_version} 은 성공적으로 빌드된 적이 없어 되돌릴 수 없다")
if site.current_version_id == version.site_version_id:
raise RollbackAborted(f"이미 지금 공개 중인 버전이다(v{target_version})")
result = {
"place_id": place_id, "site_id": str(site.site_id),
"target_version": target_version, "site_version_id": str(version.site_version_id),
}
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,
gate, payload.get("requested_by"))
result["rolled_back"] = False
result["error"] = reason
LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {reason}")
# 게이트 반려는 알리지 않는다 — build_service._fail 과 같은 규칙(운영자를 부를 일이 아니다).
if gate is None:
await alert_service.send_alert(
kind="build_failed",
title=f"롤백 실패 — {place_id} → v{target_version}",
detail=f"place_id={place_id} target_version={target_version}\n{reason}",
dedupe_key=f"build_failed:{place_id}",
)
return result
# snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조).
links = await load_channel_links(place_id)
payload_dict = await site_payload.prepare_site_payload(
place, version.snapshot, site, version, links, publish=True
)
payload_path = site_payload.write_payload(payload_dict)
result["payload_path"] = payload_path
try:
report = await render_service.render_site(payload_path, target_version, 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")
# 렌더러가 그때와 달라졌을 수 있으므로 게이트를 다시 돈다(파일 머리주석 참조) — fact 검증 게이트(1차)는 건너뛴다.
gate = publish_gate.evaluate(
PlaceCategory(place.category), version.snapshot.get("facts") or [], 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)
if not report.get("ok"):
return await _fail(str(report.get("error") or "렌더 실패"))
slug = site_payload.publish_slug(place, site)
try:
await render_service.activate_site(slug, target_version)
await azure_static.publish(slug)
except (render_service.RenderFailed, azure_static.AzurePublishError) as ex:
return await _fail(str(ex))
await indexnow.submit(slug)
now = GTime.UTC()
site_update = {
"status": SiteStatus.PUBLISHED.value,
"current_version_id": version.site_version_id,
"published_at": now,
}
await DB_SESSION_MNG.execute_lambda_claim(
sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update)
)
await _log(site.site_id, version.site_version_id, PublishResult.SUCCESS, None, payload.get("requested_by"))
await alert_service.resolve_alert(f"build_failed:{place_id}", f"롤백 성공 — {place_id} → v{target_version}")
result["rolled_back"] = True
LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림")
return result