o2o-site-AEO/solution/backend/services/blog_jobs.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

271 lines
11 KiB
Python

"""미니 블로그의 두 스윕 — 만들기와 보내기."""
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 전체를 준다."""
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))로 한 편씩 만든다 — 세 생성 경로(자동·구간·개별)가 같이 쓴다."""
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:
"""업장 하나."""
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:
"""사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다."""
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:
"""개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다."""
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),
)
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str:
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다."""
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)이 사는 오리진."""
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시."""
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시 스윕을 기다리지 않고 이 업장의 오늘 몫을 지금 보낸다."""
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}