[fix] solution/backend,deploy: 미니블로그 재고 자동 채우기를 스위치로 격리 — 기본 꺼짐

2026-10-01 대표 지시("이거는 안보내도 될것같아"). 켜면 발행된 사이트 **전체**가 대상이고
업장당 최대 30건이라 첫 회차에 AI 호출이 한꺼번에 일어난다 — 그 비용은 사람이 정할 일이지
기본값이 정할 일이 아니다. 글은 빌더의 [지금 생성하기] 로 사장님이 직접 만든다.

★ add_job 을 다시 지우지 않고 스위치로 뺀 이유: 바로 앞 커밋(4650125)이 고친 게
  "등록 자체가 없는데 주석만 새벽에 채운다고 말하는" 상태였다. 지우면 그 상태로 돌아가
  **끈 것과 빠뜨린 것이 구별되지 않는다** — 증상("메일이 안 온다")을 보고 발송 쪽을
  뒤지게 된다. 지금은 꺼져 있다는 사실이 코드에 남는다.

★ 발송(09:00)은 이 스위치와 무관하게 항상 돈다 — 손으로 만든 글도 나가야 한다.

- scheduler/__init__: BLOG_AUTO_DRAFTS_ENABLED=1 일 때만 blog-drafts 등록
- docker-compose · .env.example: 스위치 전달, 기본 0
- docs/MINI_BLOG 2절: 기본 꺼짐과 그 이유, 예전에 등록이 없었다는 실측

테스트 5건(2건 신설 — 기본 꺼짐 · 켜면 등록됨), 전부 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-10-01 10:52:31 +09:00
parent 4650125c6c
commit 519ed4a79b
5 changed files with 61 additions and 22 deletions

View File

@ -104,6 +104,13 @@ KAKAO_EVENT_DEV=0
# ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면 # ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면
# 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐. # 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐.
KAKAO_APPROVAL_PUSH_ENABLED=0 KAKAO_APPROVAL_PUSH_ENABLED=0
# 미니 블로그 재고 **자동** 채우기(매일 04:10 KST). 기본은 꺼짐 — 글은 빌더의
# [지금 생성하기] 로 사장님이 직접 만든다(2026-10-01 결정, docs/MINI_BLOG.md).
# ★ 켜면 발행된 사이트 **전체**가 대상이고 업장당 최대 30건이라, 첫 회차에 AI 호출이
# 한꺼번에 일어난다. 비용을 확인하고 켠다.
# ★ 발송(09:00)은 이 스위치와 무관하게 항상 돈다 — 손으로 만든 글도 나가야 한다.
BLOG_AUTO_DRAFTS_ENABLED=0
KAKAO_APPROVAL_EVENT_NAME=post_approval KAKAO_APPROVAL_EVENT_NAME=post_approval
# [승인] 버튼이 부르는 블록 ID(오픈빌더 블록 주소의 /intent/<ID>). 그 블록에 우리 스킬이 # [승인] 버튼이 부르는 블록 ID(오픈빌더 블록 주소의 /intent/<ID>). 그 블록에 우리 스킬이
# 스킬데이터로 연결돼 있어야 한다. ★ 비우면 [승인] 버튼을 그리지 않는다(수정하기만 나간다). # 스킬데이터로 연결돼 있어야 한다. ★ 비우면 [승인] 버튼을 그리지 않는다(수정하기만 나간다).

View File

@ -57,6 +57,9 @@ services:
<<: *common-env <<: *common-env
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다. # ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
SCHEDULER_ENABLED: "1" SCHEDULER_ENABLED: "1"
# 미니 블로그 재고 자동 채우기(04:10 KST). 기본 꺼짐 — 켜면 발행 사이트 전체에
# 업장당 30건씩 AI 호출이 일어난다(docs/MINI_BLOG.md 2절). 발송(09:00)은 무관하게 돈다.
BLOG_AUTO_DRAFTS_ENABLED: ${BLOG_AUTO_DRAFTS_ENABLED:-0}
# ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한 # ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한
# 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 — # 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 —
# 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다. # 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다.

View File

@ -61,6 +61,14 @@
`scheduler/__init__.py` 에 `add_job` 한 줄, 로직은 `scheduler/jobs.py` → `services/blog_service.py`. `scheduler/__init__.py` 에 `add_job` 한 줄, 로직은 `scheduler/jobs.py` → `services/blog_service.py`.
- **주기**: 하루 1회(KST 새벽 04:10). `SCHEDULER_ENABLED=1` 인 프로세스에서만 돈다(이미 그 규약이다) - **주기**: 하루 1회(KST 새벽 04:10). `SCHEDULER_ENABLED=1` 인 프로세스에서만 돈다(이미 그 규약이다)
- ★★ **기본으로 돌지 않는다** — `BLOG_AUTO_DRAFTS_ENABLED=1` 이어야 등록된다
(2026-10-01 대표 지시: "이거는 안보내도 될것같아"). 글은 빌더의 [지금 생성하기] 로
사장님이 직접 만든다. 켜면 발행된 사이트 **전체**가 대상이라 첫 회차에 AI 호출이
한꺼번에 일어난다 — 그 비용은 사람이 정할 일이다.
★ **발송(09:00)은 이 스위치와 무관하게 항상 돈다.** 손으로 만든 글도 나가야 한다.
★ 2026-09-30 까지는 이 잡이 **등록 자체가 없었다** — 함수와 주석("새벽에 채운다")만 있고
`add_job` 이 없어 한 번도 돈 적이 없다. 증상은 "메일이 안 온다" 로만 보이고 실제로는
보낼 글이 0건이었다. 지금은 꺼져 있다는 사실이 코드에 남는다(`scheduler/__init__.py`)
- **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 — - **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 —
하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다 하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다
- **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다 - **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다

View File

@ -40,11 +40,18 @@ def start_scheduler():
id="alert-outbox", max_instances=1, coalesce=True) id="alert-outbox", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_queue_health, "interval", minutes=5, _scheduler.add_job(sweep_queue_health, "interval", minutes=5,
id="queue-health", max_instances=1, coalesce=True) id="queue-health", max_instances=1, coalesce=True)
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md). # 미니 블로그 — 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
# ★★ 재고 채우기가 **등록돼 있지 않았다**(실측 2026-09-30). 주석만 "새벽에 채운다" 라고 #
# 말하고 `sweep_blog_drafts` 는 어디에서도 불리지 않아, 자동 생성이 한 번도 돈 적이 없다. # ★★ 재고 **자동** 채우기는 기본으로 돌지 않는다(2026-10-01 대표 지시: "이거는 안보내도
# 증상이 "메일이 안 온다" 로만 보여서 발송 쪽을 보게 된다 — 실제로는 **보낼 글이 # 될것같아"). 글은 빌더 화면의 [지금 생성하기] 로 사장님이 직접 만든다.
# 0건**이었고, 지금 쌓여 있는 글은 전부 화면의 [지금 생성하기] 로 손으로 만든 것이다. # 켜면 발행된 사이트 **전체**가 대상이고 업장당 최대 30건이라, 첫 회차에 AI 호출이
# 한꺼번에 일어난다 — 그 비용을 감수할지는 사람이 정할 일이지 기본값이 정할 일이 아니다.
#
# ★ 그래서 `add_job` 을 지우지 않고 스위치로 격리했다. 예전에는 **등록 자체가 없었고**
# 주석만 "새벽에 채운다" 라고 말했다(실측 2026-09-30) — 그 상태는 "끈 것" 과 "빠뜨린 것"
# 이 구별되지 않아, 증상("메일이 안 온다")을 보고 발송 쪽을 뒤지게 된다.
# 실제로는 보낼 글이 0건이었다. 지금은 꺼져 있다는 사실이 코드에 남는다.
if os.environ.get("BLOG_AUTO_DRAFTS_ENABLED") == "1":
# 발송(09:00)보다 앞서야 그날 아침에 나갈 재고가 있다. # 발송(09:00)보다 앞서야 그날 아침에 나갈 재고가 있다.
_scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10, _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
id="blog-drafts", max_instances=1, coalesce=True) id="blog-drafts", max_instances=1, coalesce=True)

View File

@ -12,26 +12,40 @@ import scheduler
@pytest.fixture @pytest.fixture
def started(monkeypatch): def start(monkeypatch):
"""실제로 돌리지 않는다 — 등록만 본다."""
def _start(**env):
monkeypatch.setenv("SCHEDULER_ENABLED", "1") monkeypatch.setenv("SCHEDULER_ENABLED", "1")
# 실제로 돌리지 않는다 — 등록만 본다. for k, v in env.items():
monkeypatch.setenv(k, v)
monkeypatch.setattr("apscheduler.schedulers.asyncio.AsyncIOScheduler.start", lambda self: None) monkeypatch.setattr("apscheduler.schedulers.asyncio.AsyncIOScheduler.start", lambda self: None)
scheduler._scheduler = None scheduler._scheduler = None
scheduler.start_scheduler() scheduler.start_scheduler()
yield scheduler._scheduler return scheduler._scheduler
yield _start
scheduler._scheduler = None scheduler._scheduler = None
def test_미니블로그는_생성과_발송이_둘_다_등록된다(started): def test_발송은_스위치_없이도_등록된다(start):
"""★ 발송만 있으면 보낼 글이 영원히 0건이다.""" """메일·카톡 발송은 항상 돈다 — 사장님이 직접 만든 글도 나가야 한다."""
ids = {j.id for j in started.get_jobs()} assert "blog-mail" in {j.id for j in start().get_jobs()}
assert "blog-drafts" in ids, "재고 채우기가 등록되지 않았다 — 자동 생성이 안 돈다"
assert "blog-mail" in ids
def test_재고_채우기가_발송보다_앞선다(started): def test_자동_재고채우기는_기본으로_꺼져_있다(start):
"""★ 2026-10-01 대표 지시. 켜면 발행 사이트 전체에 업장당 30건씩 AI 호출이 일어난다 —
기본값이 정할 일이 아니다."""
assert "blog-drafts" not in {j.id for j in start().get_jobs()}
def test_스위치를_켜면_등록된다(start):
"""★ 빠뜨린 것과 끈 것은 다르다 — 켜지는지까지 봐야 '격리했다' 가 성립한다."""
assert "blog-drafts" in {j.id for j in start(BLOG_AUTO_DRAFTS_ENABLED="1").get_jobs()}
def test_재고_채우기가_발송보다_앞선다(start):
"""그날 아침에 나갈 글이 있으려면 생성이 먼저여야 한다.""" """그날 아침에 나갈 글이 있으려면 생성이 먼저여야 한다."""
by_id = {j.id: j for j in started.get_jobs()} by_id = {j.id: j for j in start(BLOG_AUTO_DRAFTS_ENABLED="1").get_jobs()}
hour = lambda j: int(str(by_id[j].trigger.fields[by_id[j].trigger.FIELD_NAMES.index("hour")])) 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") assert hour("blog-drafts") < hour("blog-mail")