"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md ★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약). ★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에 못 넣어 중복이 막히지 않는다. ★ 팀 사전검수 없음 — 금칙 필터(blog_service.filter_drafts)를 통과하면 바로 REVIEWED 로 쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다. ★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). """ import uuid from datetime import date, datetime, timedelta, timezone from sqlalchemy import select from config import 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 common.models.gmodel import UserInfo from crud.post_crud import PostCRUD from router.v1.validator.dependencies import CreateDayPassToken from services import blog_service, mail_service, site_payload 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 _generate_for_place(place) -> int: """업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0).""" if await _pending_count(place.place_id) >= REFILL_BELOW: return 0 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 kind, key, material in blog_service.materials(snapshot): if len(rows) >= BATCH_SIZE: break 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, ) if not generated: continue body, model = generated rows.append({ "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, "generation_meta": {"model": model}, }) used_set.add(key) kept, dropped = blog_service.filter_drafts(rows) if kept: 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() for offset, row in enumerate(kept): row["scheduled_date"] = next_date + timedelta(days=offset) await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s, r=kept: _crud.add_many(s, r)], ) if dropped: LOG.i(f"[blog] place={place.place_id} 버린 {len(dropped)}건 — {dropped[0][1]}") return len(kept) 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 을 부르지 않고 건너뛴다 — 매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 소재가 떨어지면(구간 안에서 더 만들 topic_key 가 없으면) 그 자리에서 멈춘다 — 못 채운 나머지는 그대로 빈 날짜로 남는다.""" 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} 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 kind, key, material in blog_service.materials(snapshot): if len(rows) >= len(empty_dates): break 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, ) if not generated: continue body, model = generated ok, _reason = blog_service.is_publishable_body(body) used_set.add(key) if not ok: continue rows.append({ "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, "generation_meta": {"model": model}, "status": PostStatus.REVIEWED.value, }) for row, d in zip(rows, empty_dates): row["scheduled_date"] = d 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 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) for kind, key, material in blog_service.materials(snapshot): 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, ) if not generated: continue body, model = generated ok, _reason = blog_service.is_publishable_body(body) if not ok: used_set.add(key) # 이 주제는 이번 시도에서 다시 고르지 않는다 continue row = { "place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key, "scheduled_date": target_date, "generation_meta": {"model": model}, "status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 벌크 경로(filter_drafts)와 동일 } inserted = await DB_SESSION_MNG.execute_lambda_write( place_posts.DBType(), lambda s, r=row: _crud.add_one(s, r), ) return inserted # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다 return None def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str: """승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다 (2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에 만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는 로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신, 실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다.""" user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version) auto_token = CreateDayPassToken(user_info) edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_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: """메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진. ★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/) 전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면" 링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이 이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다. 비어 있으면(로컬에서 안 채웠으면) publish_origin() 으로 폴백해 링크가 아예 상대경로로 깨지는 것보다는 낫게 한다.""" return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin() async def _send_one(place, user, post) -> bool: """토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다.""" token, token_hash, expires = blog_service.issue_token() body = _mail_body( place_name=place.name, post=post, user=user, origin=_app_origin(), approve_token=token, ) ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body) if not ok: return False await DB_SESSION_MNG.execute_lambda_run( [place_posts.DBType()], [lambda s, pid=post.post_id, h=token_hash, e=expires: _crud.mark_sent(s, pid, h, e)], ) return True async def send_reviewed() -> int: """검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다.""" if not mail_service.is_configured(): 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 if not mail_service.is_valid_address(_notify_address(place, user) or ""): 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(): 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 mail_service.is_valid_address(_notify_address(place, user) or ""): 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}