From ade3bc0f8ee2ce6dbaee4515d784e71841354a4a Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 1 Oct 2026 11:12:44 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend,deploy:=20=EB=AF=B8?= =?UTF-8?q?=EB=8B=88=EB=B8=94=EB=A1=9C=EA=B7=B8=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=8A=A4=EC=9C=95=20=EC=88=98=EC=8B=A0=EC=9E=90=EB=A5=BC=20?= =?UTF-8?q?=EC=A2=81=ED=9E=8C=EB=8B=A4=20=E2=80=94=20BLOG=5FAUTO=5FOWNER?= =?UTF-8?q?=5FIDS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발행된 사이트가 전부 자동 생성·발송 대상이라, 테스트로 만든 업장까지 매일 알림을 받는다. 실측(2026-10-01): 발행 25곳 중 스테이,머뭄 5개·게으르게펜션 2개 등 중복·테스트 업장이 상당수였고, 수신자는 내부 계정 7명이었다. 시험 운영 중에는 받을 사람을 좁혀 둔다. - blog_jobs._for_auto_sweep: user_id 목록으로 거른다. **비면 전체**다 — 운영 기본값이라 칸이 비었다고 기능이 꺼지면 안 된다(쉼표만 있는 입력도 전체로 친다) - generate_drafts · send_reviewed 두 스윕에 **같이** 건다 — 한쪽만 걸면 만들어 놓고 안 보내거나 그 반대가 된다 ★★ 거르는 자리가 `_published_places()` 안이 아니다. 그 함수는 빌더의 [지금 생성하기] (`generate_range`·`generate_one_for_date`)도 쓰는데, 거기서 거르면 **목록 밖 사장님이 자기 화면에서 자기 글도 못 만든다** — 자동으로 보낼지와 직접 만들지는 다른 질문이다. 테스트가 그 두 함수의 소스에 `_for_auto_sweep` 이 없는지까지 본다. ★ 목록 밖 업장의 글은 건너뛸 뿐 **버리지 않는다** — REVIEWED 로 남아 목록을 풀면 그때 나간다. 거기서 SENT 로 찍으면 아무에게도 안 간 글이 보낸 것으로 기록된다. 테스트 5건 추가. blog 관련 78 passed Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 7 +++ docker-compose.yml | 2 + docs/MINI_BLOG.md | 7 +++ solution/backend/services/blog_jobs.py | 31 +++++++++- .../backend/tests/test_blog_auto_allowlist.py | 59 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 solution/backend/tests/test_blog_auto_allowlist.py diff --git a/.env.example b/.env.example index a887b06..30db340 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,13 @@ KAKAO_APPROVAL_PUSH_ENABLED=0 # 한꺼번에 일어난다. 비용을 확인하고 켠다. # ★ 발송(09:00)은 이 스위치와 무관하게 항상 돈다 — 손으로 만든 글도 나가야 한다. BLOG_AUTO_DRAFTS_ENABLED=0 + +# 자동 스윕(생성·발송)을 받을 사장님 — user_id 를 쉼표로. **비면 전체**다. +# ★ 발행된 사이트가 전부 대상이라 테스트로 만든 중복 업장까지 매일 알림을 받는다 +# (실측 2026-10-01: 발행 25곳 중 상당수가 중복·테스트 업장). 시험 운영 중에는 좁혀 둔다. +# ★ 빌더의 [지금 생성하기] 는 이 목록을 보지 않는다 — 목록 밖 사장님도 자기 글은 만든다. +# ★ 목록 밖 업장의 글은 건너뛸 뿐 버리지 않는다. 목록을 풀면 그때부터 나간다. +BLOG_AUTO_OWNER_IDS= KAKAO_APPROVAL_EVENT_NAME=post_approval # [승인] 버튼이 부르는 블록 ID(오픈빌더 블록 주소의 /intent/). 그 블록에 우리 스킬이 # 스킬데이터로 연결돼 있어야 한다. ★ 비우면 [승인] 버튼을 그리지 않는다(수정하기만 나간다). diff --git a/docker-compose.yml b/docker-compose.yml index 705b104..6efd731 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,8 @@ services: # 미니 블로그 재고 자동 채우기(04:10 KST). 기본 꺼짐 — 켜면 발행 사이트 전체에 # 업장당 30건씩 AI 호출이 일어난다(docs/MINI_BLOG.md 2절). 발송(09:00)은 무관하게 돈다. BLOG_AUTO_DRAFTS_ENABLED: ${BLOG_AUTO_DRAFTS_ENABLED:-0} + # 자동 스윕을 받을 사장님(user_id 쉼표 구분). 비면 전체 — 수동 생성은 안 걸린다. + BLOG_AUTO_OWNER_IDS: ${BLOG_AUTO_OWNER_IDS:-} # ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한 # 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 — # 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다. diff --git a/docs/MINI_BLOG.md b/docs/MINI_BLOG.md index 5a359b2..553fff9 100644 --- a/docs/MINI_BLOG.md +++ b/docs/MINI_BLOG.md @@ -71,6 +71,13 @@ 보낼 글이 0건이었다. 지금은 꺼져 있다는 사실이 코드에 남는다(`scheduler/__init__.py`) - **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 — 하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다 +- ★ **`BLOG_AUTO_OWNER_IDS` 로 받을 사장님을 좁힐 수 있다**(쉼표 구분 user_id, 비면 전체). + 생성·발송 **두 스윕에 같이** 걸린다 — 한쪽만 걸면 만들어 놓고 안 보내거나 그 반대가 된다. + ★★ 거르는 자리는 `blog_jobs._for_auto_sweep` 이고 `_published_places()` 안이 아니다. + 그 함수는 빌더의 [지금 생성하기](`generate_range`·`generate_one_for_date`)도 쓰는데, + 거기서 거르면 **목록 밖 사장님이 자기 화면에서 자기 글도 못 만든다**. + ★ 목록 밖 업장의 글은 건너뛸 뿐 **버리지 않는다** — REVIEWED 로 남아 목록을 풀면 나간다. + 거기서 SENT 로 찍으면 아무에게도 안 간 글이 보낸 것으로 기록된다 - **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다 (한 달치를 한 호출로 뽑으면 중복 검사가 안 된다) - **배정일**: 그 업장의 `MAX(scheduled_date)` 다음날부터(없으면 오늘부터, KST) 하루 한 건씩 diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index 6ccbbb5..b902bd7 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -8,6 +8,7 @@ ★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). """ +import os import uuid from datetime import date, datetime, timedelta, timezone @@ -58,6 +59,29 @@ async def _published_places() -> list: return list(result.all()) if result is not None else [] +def _auto_owner_allowlist() -> set: + """자동 스윕을 받을 사장님 목록(`BLOG_AUTO_OWNER_IDS`, 쉼표 구분 user_id). + + ★ 비면 **전체**다 — 운영 기본값이고, 이 칸이 비었다고 기능이 꺼지면 안 된다. + ★ 왜 필요한가: 발행된 사이트가 전부 대상이라 **테스트로 만든 업장까지** 매일 알림을 + 받는다(실측 2026-10-01: 발행 25곳 중 상당수가 중복·테스트 업장이었다). + 시험 운영 중에는 받을 사람을 좁혀 둔다.""" + return {x.strip() for x in os.environ.get("BLOG_AUTO_OWNER_IDS", "").split(",") if x.strip()} + + +def _for_auto_sweep(pairs: list) -> list: + """자동 스윕(생성·발송) 대상만 남긴다. + + ★★ **`_published_places()` 안에서 거르지 않는다.** 그 함수는 빌더의 [지금 생성하기] + (`generate_range` · `generate_one_for_date`)도 쓰는데, 거기서 걸러 버리면 목록에 없는 + 사장님은 **자기 화면에서 자기 글도 못 만든다** — 자동으로 보낼지와 직접 만들지는 + 다른 질문이다. 그래서 두 스윕에서만 부른다.""" + allow = _auto_owner_allowlist() + if not allow: + return pairs + return [(place, user) for place, user in pairs if str(user.user_id) in allow] + + async def _pending_count(place_id) -> int: """아직 사장님에게 안 나간 재고 — 팀 사전검수가 없어 생성 즉시 REVIEWED 로 쌓인다.""" def query(session): @@ -136,7 +160,7 @@ async def _generate_for_place(place) -> int: async def generate_drafts() -> int: """재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다.""" made = 0 - for place, _user in await _published_places(): + for place, _user in _for_auto_sweep(await _published_places()): made += await _generate_for_place(place) return made @@ -321,7 +345,10 @@ async def send_reviewed() -> int: if not rows: return 0 - places_by_id = {str(place.place_id): (place, user) for place, user in await _published_places()} + # ★ 목록 밖 업장의 글은 건너뛸 뿐 **버리지 않는다** — REVIEWED 로 남아 있어서, 목록을 + # 풀면 그때 나간다. 여기서 SENT 로 찍으면 아무에게도 안 간 글이 보낸 것으로 기록된다. + places_by_id = {str(place.place_id): (place, user) + for place, user in _for_auto_sweep(await _published_places())} sent = 0 for post in rows: target = places_by_id.get(str(post.place_id)) diff --git a/solution/backend/tests/test_blog_auto_allowlist.py b/solution/backend/tests/test_blog_auto_allowlist.py new file mode 100644 index 0000000..943be0c --- /dev/null +++ b/solution/backend/tests/test_blog_auto_allowlist.py @@ -0,0 +1,59 @@ +"""자동 스윕 수신자 제한 — `BLOG_AUTO_OWNER_IDS`. + +★ 왜 있나: 발행된 사이트가 전부 자동 생성·발송 대상이라, 테스트로 만든 중복 업장까지 + 매일 알림을 받는다(실측 2026-10-01: 발행 25곳 중 상당수가 중복·테스트 업장). +★★ 거르는 자리가 중요하다. `_published_places()` 안에서 거르면 빌더의 [지금 생성하기] 도 + 같이 막혀 **목록 밖 사장님이 자기 글도 못 만든다** — 자동으로 보낼지와 직접 만들지는 + 다른 질문이다. 그래서 두 스윕에서만 거른다. +""" +from types import SimpleNamespace + +import pytest + +from services import blog_jobs + +A = "11111111-1111-1111-1111-111111111111" +B = "22222222-2222-2222-2222-222222222222" + + +def _pair(user_id, place_name): + return (SimpleNamespace(place_id=f"place-{place_name}", name=place_name), + SimpleNamespace(user_id=user_id)) + + +@pytest.fixture +def pairs(): + return [_pair(A, "내 숙소"), _pair(B, "남의 숙소"), _pair(B, "테스트 중복")] + + +def test_비어_있으면_전체가_대상이다(monkeypatch, pairs): + """★ 운영 기본값이다 — 칸이 비었다고 기능이 꺼지면 안 된다.""" + monkeypatch.delenv("BLOG_AUTO_OWNER_IDS", raising=False) + assert blog_jobs._for_auto_sweep(pairs) == pairs + + +def test_목록에_있는_사장님만_남는다(monkeypatch, pairs): + monkeypatch.setenv("BLOG_AUTO_OWNER_IDS", A) + assert [p.name for p, _ in blog_jobs._for_auto_sweep(pairs)] == ["내 숙소"] + + +def test_쉼표로_여럿_공백은_무시한다(monkeypatch, pairs): + monkeypatch.setenv("BLOG_AUTO_OWNER_IDS", f" {A} , {B} ") + assert len(blog_jobs._for_auto_sweep(pairs)) == 3 + + +def test_쉼표만_있으면_전체로_친다(monkeypatch, pairs): + """빈 항목만 남는 입력을 '아무도 없음' 으로 읽으면 알림이 통째로 멎는다.""" + monkeypatch.setenv("BLOG_AUTO_OWNER_IDS", " , , ") + assert blog_jobs._for_auto_sweep(pairs) == pairs + + +def test_수동_생성_경로는_이_목록을_보지_않는다(): + """★★ 목록 밖 사장님도 빌더에서 자기 글은 만들 수 있어야 한다. + `generate_range` · `generate_one_for_date` 가 `_for_auto_sweep` 을 부르면 안 된다.""" + import inspect + + for fn in (blog_jobs.generate_range, blog_jobs.generate_one_for_date): + assert "_for_auto_sweep" not in inspect.getsource(fn), ( + f"{fn.__name__} 이 자동 스윕 목록을 본다 — 수동 생성까지 막힌다" + )