결정(2026-09-29): 카톡과 메일 둘 다, 승인은 링크가 아니라 연결된 계정 신원으로. 이 커밋은 발송과 메시지 그리기까지고 [승인] 버튼은 다음 단계(2-2)다. - blog_jobs._send_one: 메일에 더해 Event API 로 보낸다. 하나라도 나가면 SENT, 아무 데도 안 나가면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 카톡은 채널 친구가 아니거나 차단했으면 실패하므로 메일을 빼지 않는다. send_now 도 같은 경로 - 카톡 params 로 post_id 와 수정용 일회용 코드(edit_token)를 넘긴다. 코드 평문은 발송 시점에만 알아서다. 로그에는 params 의 키만 남기고 값은 남기지 않는다 - channel.approval_notice: 발화자 키 → 사장님 → 그 글이 그 사장님 가게 것·미처리· 기한 전일 때만 본문과 [수정하기] 를 준다. 연결 안 됨·남의 글·처리됨·만료·이상한 ID 는 구분 없이 같은 안내(구분해 주면 글 ID 를 탐색할 수 있다) - kakao_bot: userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리하고 링크 버튼은 본문과 따로 textCard 로 그린다(카드 설명 길이 제한을 피한다) - KAKAO_APPROVAL_PUSH_ENABLED(기본 0), KAKAO_APPROVAL_EVENT_NAME 추가 — 오픈빌더 이벤트 블록(스킬 연결)과 배포가 끝나기 전에는 켜지 않는다 test_blog_owner 6건·test_kakao_webhook 6건 추가, 카카오·미니블로그 스위트 119 passed, 인접 스위트 110 passed. 실제 카톡 수신은 콘솔 설정·운영 배포 뒤에 확인한다.
366 lines
17 KiB
Python
366 lines
17 KiB
Python
"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md
|
|
|
|
★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약).
|
|
★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에
|
|
못 넣어 중복이 막히지 않는다.
|
|
★ 팀 사전검수 없음 — 금칙 필터(blog_service.is_publishable_body)를 통과하면 바로 REVIEWED 로
|
|
쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다.
|
|
★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼
|
|
것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17).
|
|
"""
|
|
import uuid
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
|
|
from config import agent_config, social_config
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import place_posts, places, sites, users
|
|
from common.enums import DBWRType, PostStatus, SiteStatus
|
|
from common.logger import LOG
|
|
from crud.post_crud import PostCRUD
|
|
from services import blog_service, kakao_link_service, mail_service, site_payload
|
|
from services.external import kakao_event
|
|
from services.snapshot import build_snapshot
|
|
|
|
BATCH_SIZE = 30
|
|
# 이 수보다 재고(DRAFT)가 적은 업장만 새로 만든다 — 한 달치(하루 한 통 기준 약 30일)를 채운다.
|
|
REFILL_BELOW = 30
|
|
MAIL_PER_SWEEP = 20
|
|
|
|
_KST = timezone(timedelta(hours=9))
|
|
|
|
_crud = PostCRUD()
|
|
|
|
|
|
def _today_kst() -> date:
|
|
return datetime.now(_KST).date()
|
|
|
|
|
|
async def _published_places() -> list:
|
|
"""(place, user) 쌍 — user 전체를 준다. 메일에 email 뿐 아니라(대상 판정) 로그인
|
|
day-pass 토큰(id·role·token_version)도 만들어야 해서 email 만으로는 부족하다."""
|
|
def query(session):
|
|
return session.execute(
|
|
select(places, users)
|
|
.join(sites, sites.place_id == places.place_id)
|
|
.join(users, users.user_id == places.owner_user_id)
|
|
.where(
|
|
places.deleted == False, # noqa: E712
|
|
sites.deleted == False, # noqa: E712
|
|
sites.status == SiteStatus.PUBLISHED.value,
|
|
sites.domain.isnot(None),
|
|
)
|
|
)
|
|
|
|
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
|
|
return list(result.all()) if result is not None else []
|
|
|
|
|
|
async def _pending_count(place_id) -> int:
|
|
"""아직 사장님에게 안 나간 재고 — 팀 사전검수가 없어 생성 즉시 REVIEWED 로 쌓인다."""
|
|
def query(session):
|
|
return session.execute(
|
|
select(place_posts.post_id).where(
|
|
place_posts.place_id == place_id,
|
|
place_posts.status.in_((PostStatus.DRAFT.value, PostStatus.REVIEWED.value)),
|
|
place_posts.deleted == False, # noqa: E712
|
|
)
|
|
)
|
|
|
|
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
|
|
return len(result.all()) if result is not None else 0
|
|
|
|
|
|
async def _compose_for_dates(place, dates: list[date]) -> list[dict]:
|
|
"""날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 —
|
|
세 생성 경로(자동·구간·개별)가 같이 쓴다. 저장은 부르는 쪽이 한다.
|
|
|
|
★ 날짜를 먼저 정하고 소재를 고른다(2026-09-23). 예전에는 소재 목록을 순서대로 뽑아 날짜에
|
|
차례로 붙여서, 글 내용이 배정된 날짜와 무관했다.
|
|
★ 그 날짜에 맞는 소재가 없으면 그 날짜만 비워 두고 다음 날짜로 간다 — 뒤 날짜엔 축제가 걸릴 수 있다.
|
|
★ LLM 이 없거나 실패하면(None) 그 자리에서 멈춘다 — 날짜마다 소재를 전부 돌며 헛호출하지 않는다."""
|
|
used = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
|
|
)
|
|
used_set = set(used or [])
|
|
snapshot = await build_snapshot(place)
|
|
region = site_payload.region_label(place.road_address, place.address)
|
|
|
|
rows = []
|
|
for target in dates:
|
|
for kind, key, material in blog_service.materials(snapshot, target):
|
|
if key in used_set:
|
|
continue
|
|
generated = await blog_service.generate_one(
|
|
place_name=place.name, region=region, topic_kind=kind, material=material,
|
|
used_topics=sorted(used_set), place_category=place.category, post_date=target,
|
|
)
|
|
if not generated:
|
|
return rows
|
|
body, model = generated
|
|
ok, reason = blog_service.is_publishable_body(body)
|
|
used_set.add(key) # 버린 주제도 이번 회차에서 다시 고르지 않는다
|
|
if not ok:
|
|
LOG.i(f"[blog] place={place.place_id} {target} 버림 — {reason}")
|
|
continue
|
|
rows.append({
|
|
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
|
|
"scheduled_date": target, "generation_meta": {"model": model},
|
|
"status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음
|
|
})
|
|
break
|
|
return rows
|
|
|
|
|
|
async def _generate_for_place(place) -> int:
|
|
"""업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0)."""
|
|
if await _pending_count(place.place_id) >= REFILL_BELOW:
|
|
return 0
|
|
|
|
latest = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid),
|
|
)
|
|
next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst()
|
|
rows = await _compose_for_dates(place, [next_date + timedelta(days=i) for i in range(BATCH_SIZE)])
|
|
if rows:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
)
|
|
return len(rows)
|
|
|
|
|
|
async def generate_drafts() -> int:
|
|
"""재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다."""
|
|
made = 0
|
|
for place, _user in await _published_places():
|
|
made += await _generate_for_place(place)
|
|
return made
|
|
|
|
|
|
async def generate_range(place_id: str, start_date: date, end_date: date) -> dict:
|
|
"""사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다
|
|
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").
|
|
재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에
|
|
상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 —
|
|
매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 그 날짜에 맞는 소재가 없으면
|
|
그 날짜는 빈 날짜로 남는다(_compose_for_dates)."""
|
|
place = None
|
|
for p, _user in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place = p
|
|
break
|
|
if place is None:
|
|
return {"requested": 0, "created": 0}
|
|
|
|
requested = (end_date - start_date).days + 1
|
|
dates = [start_date + timedelta(days=i) for i in range(requested)]
|
|
|
|
_err, existing_rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.list_for_place(s, pid, start_date, end_date + timedelta(days=1)),
|
|
)
|
|
taken = {row.scheduled_date for row in existing_rows}
|
|
empty_dates = [d for d in dates if d not in taken]
|
|
if not empty_dates:
|
|
return {"requested": requested, "created": 0}
|
|
|
|
rows = await _compose_for_dates(place, empty_dates)
|
|
if rows:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
)
|
|
return {"requested": requested, "created": len(rows)}
|
|
|
|
|
|
async def generate_one_for_date(place_id: str, target_date: date) -> dict | None:
|
|
"""개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다(2026-09-17, 사장님 지시:
|
|
"개별적으로 새로 만들수있게 해줘"). 재고 상한(REFILL_BELOW)을 안 본다 — 특정 날짜를
|
|
지정한 요청이라 상한 로직이 끼어들 자리가 아니다. 그 날짜가 이미 차 있으면 None."""
|
|
place = None
|
|
for p, _user in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place = p
|
|
break
|
|
if place is None:
|
|
return None
|
|
|
|
rows = await _compose_for_dates(place, [target_date])
|
|
if not rows:
|
|
return None
|
|
return await DB_SESSION_MNG.execute_lambda_write(
|
|
place_posts.DBType(), lambda s, r=rows[0]: _crud.add_one(s, r),
|
|
) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
|
|
|
|
|
|
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str, edit_token: str) -> str:
|
|
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
|
|
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
|
|
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
|
|
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
|
|
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
|
|
# ★ 두 링크가 같은 모양이다 — 일회용 코드 하나씩. 예전에는 수정 링크만 500자였는데,
|
|
# 길이보다 나쁜 것은 거기 실린 빌더 액세스 토큰이었다(services/post_service.open_editor).
|
|
edit_link = f"{origin}/v1/site/post/edit?t={edit_token}"
|
|
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
|
|
return (
|
|
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
|
|
f"{post.body}\n\n"
|
|
f"이대로 올리려면(누르면 바로 게재됩니다):\n{approve_link}\n\n"
|
|
f"고쳐서 올리려면:\n{edit_link}\n\n"
|
|
f"두 링크 모두 오늘 자정(KST)에 만료됩니다. 그 뒤엔 로그인해서 빌더 앱에서 처리해 주세요.\n"
|
|
f"— 이 메일은 Web4AI 가 자동으로 보냈습니다."
|
|
)
|
|
|
|
|
|
def _notify_address(place, user) -> str:
|
|
# notify_email 이 있으면 그 업장 전용 수신자다 — 없으면 계정 이메일(users.email)로 대체한다
|
|
# (사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다).
|
|
return place.notify_email or user.email
|
|
|
|
|
|
def _app_origin() -> str:
|
|
"""blog_service.app_origin() 을 그대로 쓴다 — 라우터도 같은 값을 써야 해서 거기로 옮겼다."""
|
|
return blog_service.app_origin()
|
|
|
|
|
|
def _kakao_push_ready() -> bool:
|
|
"""카톡 승인 알림을 보낼 수 있는 환경인가. DB 를 안 본다 — 스윕 맨 앞에서 싸게 거른다."""
|
|
return agent_config.approval_push_enabled() and kakao_event.is_configured()
|
|
|
|
|
|
async def _kakao_target(user) -> str | None:
|
|
"""이 사장님에게 카톡으로 보낼 수 있으면 발화자 키, 아니면 None(스위치 꺼짐·미연결·끊김)."""
|
|
if not _kakao_push_ready():
|
|
return None
|
|
return await kakao_link_service.linked_key(user.user_id)
|
|
|
|
|
|
def _can_mail(place, user) -> bool:
|
|
return mail_service.is_configured() and mail_service.is_valid_address(_notify_address(place, user) or "")
|
|
|
|
|
|
async def _push_kakao(channel_user_key: str, post, edit_token: str) -> bool:
|
|
"""승인 알림을 카톡으로. 실패해도 예외를 올리지 않는다 — 친구가 아니거나 차단했으면
|
|
실패하는 게 정상 경로라, 호출부가 메일로 대신할 수 있어야 한다.
|
|
|
|
★ 글 본문·수정 링크는 여기서 만들지 않는다. 오픈빌더 이벤트 블록의 스킬(우리 웹훅)이
|
|
params 의 글 ID 로 **연결된 본인 글인지 다시 확인한 뒤** 그린다. 수정 링크용 일회용
|
|
코드는 평문을 지금밖에 모르므로 params 로 넘긴다(메일과 같은 코드다)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
await kakao_event.send(
|
|
channel_user_key,
|
|
agent_config.get("KAKAO_APPROVAL_EVENT_NAME", "post_approval"),
|
|
params={"post_id": str(post.post_id), "edit_token": edit_token},
|
|
client=client,
|
|
)
|
|
return True
|
|
except kakao_event.KakaoEventError as ex:
|
|
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림 실패: {ex}")
|
|
except httpx.HTTPError as ex:
|
|
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림 실패: {type(ex).__name__}")
|
|
return False
|
|
|
|
|
|
async def _send_one(place, user, post) -> bool:
|
|
"""토큰 발급 → 메일·카톡 발송 → 하나라도 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면
|
|
DB 를 안 건드린다(다시 시도할 길을 막지 않는다).
|
|
|
|
★ 카톡과 메일은 **둘 다** 보낸다(2026-09-29 결정). 카톡은 채널 친구가 아니거나 차단했으면
|
|
실패하므로, 카톡이 붙어도 메일을 빼지 않는다."""
|
|
token, token_hash, expires = blog_service.issue_token()
|
|
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
|
|
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
|
|
edit_token, edit_hash, _edit_expires = blog_service.issue_token()
|
|
|
|
mail_ok = False
|
|
if _can_mail(place, user):
|
|
body = _mail_body(
|
|
place_name=place.name, post=post, user=user,
|
|
origin=_app_origin(), approve_token=token, edit_token=edit_token,
|
|
)
|
|
mail_ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
|
|
|
|
async def mark_sent():
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()],
|
|
[lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
|
|
)
|
|
|
|
# ★ 메일이 나갔으면 카톡보다 **먼저** SENT 로 표시한다. 이벤트가 나가자마자 웹훅이 이 글을
|
|
# 읽는데, 그때 수정 코드 해시가 저장돼 있으면 [수정하기] 가 바로 먹는다. 카톡만 나가는
|
|
# 경우는 발송이 성공해야 표시할 수 있어 그 뒤에 한다 — 사장님이 버튼을 누르기까지의
|
|
# 시간이 그 간격보다 훨씬 길다(웹훅은 REVIEWED 글도 읽는다).
|
|
if mail_ok:
|
|
await mark_sent()
|
|
|
|
kakao_key = await _kakao_target(user)
|
|
if kakao_key is None:
|
|
return mail_ok
|
|
|
|
kakao_ok = await _push_kakao(kakao_key, post, edit_token)
|
|
if kakao_ok and not mail_ok:
|
|
await mark_sent()
|
|
return mail_ok or kakao_ok
|
|
|
|
|
|
async def send_reviewed() -> int:
|
|
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
|
|
if not mail_service.is_configured() and not _kakao_push_ready():
|
|
return 0
|
|
|
|
_err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: _crud.due_for_mail(s, PostStatus.REVIEWED.value, _today_kst(), MAIL_PER_SWEEP),
|
|
)
|
|
if not rows:
|
|
return 0
|
|
|
|
places_by_id = {str(place.place_id): (place, user) for place, user in await _published_places()}
|
|
sent = 0
|
|
for post in rows:
|
|
target = places_by_id.get(str(post.place_id))
|
|
if not target:
|
|
continue
|
|
place, user = target
|
|
# 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 하나라도 되면 _send_one 이 갈래를 정한다.
|
|
if not _can_mail(place, user) and await _kakao_target(user) is None:
|
|
continue
|
|
if await _send_one(place, user, post):
|
|
sent += 1
|
|
return sent
|
|
|
|
|
|
async def send_now_for_place(place_id: str) -> dict:
|
|
"""사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의
|
|
오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록").
|
|
'하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다."""
|
|
if not mail_service.is_configured() and not _kakao_push_ready():
|
|
return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"}
|
|
|
|
place = user = None
|
|
for p, u in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place, user = p, u
|
|
break
|
|
if place is None:
|
|
return {"sent": False, "reason": "NOTHING_DUE"}
|
|
|
|
post = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: _crud.next_due_for_mail(s, place_id, PostStatus.REVIEWED.value, _today_kst()),
|
|
)
|
|
if post is None:
|
|
return {"sent": False, "reason": "NOTHING_DUE"}
|
|
|
|
if not _can_mail(place, user) and await _kakao_target(user) is None:
|
|
return {"sent": False, "reason": "NO_VALID_EMAIL"}
|
|
|
|
if not await _send_one(place, user, post):
|
|
return {"sent": False, "reason": "SEND_FAILED"}
|
|
return {"sent": True, "reason": None}
|