[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>
This commit is contained in:
hbyang 2026-09-30 17:31:10 +09:00
parent 34d9d12c3f
commit 4650125c6c
6 changed files with 124 additions and 5 deletions

View File

@ -33,12 +33,21 @@ def start_scheduler():
_scheduler.add_job(run_scheduled_check, "interval", minutes=10,
id="search-console", max_instances=1, coalesce=True)
from scheduler.jobs import sweep_alert_outbox, sweep_blog_mail, sweep_queue_health
from scheduler.jobs import (
sweep_alert_outbox, sweep_blog_drafts, sweep_blog_mail, sweep_queue_health,
)
_scheduler.add_job(sweep_alert_outbox, "interval", minutes=1,
id="alert-outbox", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
id="queue-health", max_instances=1, coalesce=True)
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
# ★★ 재고 채우기가 **등록돼 있지 않았다**(실측 2026-09-30). 주석만 "새벽에 채운다" 라고
# 말하고 `sweep_blog_drafts` 는 어디에서도 불리지 않아, 자동 생성이 한 번도 돈 적이 없다.
# 증상이 "메일이 안 온다" 로만 보여서 발송 쪽을 보게 된다 — 실제로는 **보낼 글이
# 0건**이었고, 지금 쌓여 있는 글은 전부 화면의 [지금 생성하기] 로 손으로 만든 것이다.
# 발송(09:00)보다 앞서야 그날 아침에 나갈 재고가 있다.
_scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
id="blog-drafts", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
id="blog-mail", max_instances=1, coalesce=True)
_scheduler.start()

View File

@ -102,6 +102,26 @@ async def load_channel_links(place_id: str) -> list:
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(
@ -111,7 +131,7 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
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,
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)])

View File

@ -452,8 +452,13 @@ class PostService:
job_id, _created = await enqueue_job(
self.queue, JobType.BUILD,
# ★ requested_by 는 **사용자 ID 칸**이다(site_publish_logs.actor_user_id).
# 예전엔 여기에 "blog-approval" 이라는 라벨을 넣었는데, build_service 가 그걸
# uuid.UUID() 에 넣다 죽어 **굽기는 끝났는데 발행 상태만 안 바뀌는** 잡이
# 5건 쌓였다(2026-09-23~09-30). 승인을 누른 건 사장님이므로 그 ID 를 넣는다 —
# 어느 경로로 왔는지는 아래 LOG 한 줄이 남긴다.
{"place_id": str(place_id), "owner_user_id": str(owner_user_id),
"publish": True, "requested_by": "blog-approval"},
"publish": True, "requested_by": str(owner_user_id)},
dedupe_key=f"build:{place_id}",
)
LOG.i(f"[blog] post={post_id} → build job={job_id}")

View File

@ -18,7 +18,7 @@ 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.build_service import _actor_uuid, ensure_site, load_channel_links
_site_crud = SiteCRUD()
_place_crud = PlaceCRUD()
@ -38,7 +38,9 @@ async def _log(site_id, version_id, result: PublishResult, gate=None, actor=None
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,
# ★ 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)])

View File

@ -348,3 +348,42 @@ async def test_렌더_보고서의_값이_그대로_박제된다(auth_headers, c
assert row["jsonld"], "렌더러가 내보낸 JSON-LD 가 박제되지 않았다"
# 렌더러 대역이 내보낸 노드가 그대로 들어와야 한다(백엔드가 다시 만들지 않는다).
assert row["jsonld"][0]["@type"] == "LodgingBusiness"
# ── requested_by 가 사용자 ID 가 아닐 때 (2026-09-30) ──────────────────────────
#
# ★ 실측: 미니블로그 승인 경로가 `requested_by="blog-approval"` 이라는 **라벨**을 넣었고,
# `_log` 가 그걸 uuid.UUID() 에 넣다 ValueError 를 던졌다. `_log` 는 사이트를 다 구운 뒤
# `sites.status = PUBLISHED` 를 찍기 **전에** 불리므로, 굽기는 끝났는데 발행만 안 된 채
# 3회 재시도 후 DEAD 가 됐다(BUILD 잡 5건, 2026-09-23~09-30).
# 사장님 화면에는 "카톡으로 승인했는데 홈페이지가 그대로" 로만 보인다.
import uuid as _uuid
from services.build_service import _actor_uuid
def test_actor_가_사용자_ID_가_아니면_기록만_비우고_넘어간다():
"""던지면 발행이 죽는 자리다 — 감사 기록 한 줄이 발행을 막으면 순서가 뒤집힌 것이다."""
assert _actor_uuid("blog-approval") is None
def test_actor_가_비면_None():
assert _actor_uuid(None) is None
assert _actor_uuid("") is None
def test_정상_사용자_ID_는_그대로_UUID_가_된다():
uid = "6dafad19-48fd-4692-8944-4ba76359205f"
assert _actor_uuid(uid) == _uuid.UUID(uid)
def test_미니블로그_승인은_사장님_ID_를_requested_by_로_넣는다():
"""★ 라벨을 다시 넣으면 같은 사고가 난다 — 호출부에서도 못 박는다."""
import inspect
from services.post_service import PostService
src = inspect.getsource(PostService._enqueue_build)
assert '"requested_by": "blog-approval"' not in src
assert '"requested_by": str(owner_user_id)' in src

View File

@ -0,0 +1,44 @@
"""스케줄러에 무엇이 등록돼 있나.
★★ 이 파일이 있는 이유: `sweep_blog_drafts` 가 **등록돼 있지 않았다**(실측 2026-09-30).
`scheduler/jobs.py` 에 함수는 있고 주석도 "새벽에 재고를 채운다" 라고 말하는데
`add_job` 한 줄이 없어 한 번도 돈 적이 없다. 증상은 "미니블로그 메일이 안 온다" 로만
보여서 발송 쪽을 보게 된다 — 실제로는 **보낼 글이 0건**이었다.
잡 하나가 빠져도 서버는 정상으로 뜨고 로그에도 에러가 없다. 그래서 세는 것이다.
"""
import pytest
import scheduler
@pytest.fixture
def started(monkeypatch):
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
# 실제로 돌리지 않는다 — 등록만 본다.
monkeypatch.setattr("apscheduler.schedulers.asyncio.AsyncIOScheduler.start", lambda self: None)
scheduler._scheduler = None
scheduler.start_scheduler()
yield scheduler._scheduler
scheduler._scheduler = None
def test_미니블로그는_생성과_발송이_둘_다_등록된다(started):
"""★ 발송만 있으면 보낼 글이 영원히 0건이다."""
ids = {j.id for j in started.get_jobs()}
assert "blog-drafts" in ids, "재고 채우기가 등록되지 않았다 — 자동 생성이 안 돈다"
assert "blog-mail" in ids
def test_재고_채우기가_발송보다_앞선다(started):
"""그날 아침에 나갈 글이 있으려면 생성이 먼저여야 한다."""
by_id = {j.id: j for j in started.get_jobs()}
hour = lambda j: int(str(by_id[j].trigger.fields[by_id[j].trigger.FIELD_NAMES.index("hour")]))
assert hour("blog-drafts") < hour("blog-mail")
def test_스위치가_꺼지면_아무것도_등록하지_않는다(monkeypatch):
"""키 없는 환경에 돌지도 않을 잡을 만들어 두지 않는다."""
monkeypatch.setenv("SCHEDULER_ENABLED", "0")
scheduler._scheduler = None
scheduler.start_scheduler()
assert scheduler._scheduler is None