o2o-site-AEO/solution/backend/services/post_service.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

370 lines
16 KiB
Python

"""미니 블로그 승인 처리."""
import uuid
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD
from crud.post_crud import PostCRUD
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import jobs as jobs_table, place_posts, places, sites
from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus, SiteStatus
from common.logger import LOG
from common.models.gmodel import Res_WebPacketProtocol, UserInfo
from common.utils.gtime import GTime
from router.v1.site.protocol import (
GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
)
from services import blog_jobs, blog_service, site_payload, social_service
from services.job_service import enqueue_job
EXPIRED = "처리할 수 없는 링크입니다."
APPROVED = "올렸습니다."
SKIPPED = "이번 글은 넘겼습니다."
_KST = timezone(timedelta(hours=9))
def _month_range(month: str | None) -> tuple[date, date]:
""""YYYY-MM"(KST 기준, 없으면 이번 달) → 날짜 경계 [시작, 다음달 시작)."""
now_kst = datetime.now(_KST)
year, mon = (int(part) for part in month.split("-")) if month else (now_kst.year, now_kst.month)
start = date(year, mon, 1)
end = date(year + (mon == 12), mon % 12 + 1, 1)
return start, end
class PostService:
def __init__(self):
self.crud = PostCRUD()
self.place_crud = PlaceCRUD()
self.queue = JobQueue()
async def find_by_token(self, token: str):
"""살아 있는 토큰이면 글, 아니면 None."""
token_hash = blog_service.hash_token(token)
post = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_token_hash(s, token_hash),
)
if not post or post.status != PostStatus.SENT.value:
return None
if post.token_expires_at and post.token_expires_at.replace(tzinfo=None) < GTime.UTC():
return None
return post
async def decide(self, token: str, *, skip: bool) -> dict:
post = await self.find_by_token(token)
if not post:
return {"success": False, "message": EXPIRED}
post_id, place_id = post.post_id, post.place_id
if skip:
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.skip(s, post_id)],
)
return {"success": True, "message": SKIPPED}
await self._approve_and_publish(post_id, place_id)
return {
"success": True,
"message": APPROVED,
"redirect_url": await self._blog_url(place_id),
}
async def _blog_url(self, place_id) -> str | None:
"""이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리."""
def query(session):
return session.execute(
select(places, sites)
.join(sites, sites.place_id == places.place_id)
.where(
places.place_id == place_id,
places.deleted == False, # noqa: E712
sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
.limit(1)
)
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
if not row:
return None
place, site = row
return f"{site_payload.publish_url(place, site)}#blog"
async def _load_place(self, user_info: UserInfo, place_id: str):
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
return ErrorType.SUCCESS, place
async def list_for_owner(self, user_info: UserInfo, place_id: str, month: str | None) -> Res_MyPosts:
"""빌더 앱 — 이번 달(또는 고른 달) 생성된 글 전체."""
res = Res_MyPosts()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
since, until = _month_range(month)
res.posts = await self._fetch_posts(place_id, since, until)
return res
async def list_upcoming(self, user_info: UserInfo, place_id: str, days: int = 7) -> Res_MyPosts:
"""빌더 앱 상단 카로셀 — 오늘부터 days 일치, 날짜 오름차순."""
res = Res_MyPosts()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
today = datetime.now(_KST).date()
res.posts = await self._fetch_posts(place_id, today, today + timedelta(days=days))
res.posts.sort(key=lambda post: post.scheduled_date or date.max)
return res
async def get_post(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_MyPosts:
"""메일 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다."""
res = Res_MyPosts()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
row = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_id(s, uuid.UUID(post_id)),
)
if row is not None and str(row.place_id) == str(place_id):
res.posts = [PostData.model_validate(row)]
return res
async def generation_history(self, user_info: UserInfo, place_id: str) -> Res_GenerationHistory:
"""생성 이력 — 언제 몇 건 만들었는지."""
res = Res_GenerationHistory()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
rows = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.generation_batches(s, uuid.UUID(place_id)),
)
res.batches = [
GenerationBatch(created_at=created_at, count=count, model=model)
for created_at, count, model in (rows or [])
]
return res
async def _fetch_posts(self, place_id: str, since: date, until: date) -> list[PostData]:
_err, rows = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.list_for_place(s, uuid.UUID(place_id), since, until),
)
posts = [PostData.model_validate(row) for row in (rows or [])]
# 승인됐는데 아직 안 나간 글이 있을 때만 잡을 들여다본다 — 화면은 발행완료/발행실패만 보여주면 되고(사장님 지시), 그 판정에 필요한 만큼만 조회한다.
if any(post.status == PostStatus.APPROVED.value for post in posts):
if await self._latest_build_failed(place_id):
for post in posts:
if post.status == PostStatus.APPROVED.value:
post.build_failed = True
return posts
async def _latest_build_failed(self, place_id) -> bool:
"""이 업장의 가장 최근 BUILD 잡이 dead-letter 로 끝났는가."""
def query(session):
return session.execute(
select(jobs_table.status)
.where(
jobs_table.job_type == JobType.BUILD.value,
jobs_table.payload["place_id"].astext == str(place_id),
)
.order_by(jobs_table.created_at.desc())
.limit(1)
)
result = await DB_SESSION_MNG.execute_lambda(jobs_table.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
return bool(row) and row[0] == JobStatus.DEAD.value
async def edit_by_owner(
self, user_info: UserInfo, place_id: str, post_id: str, body: str
) -> Res_WebPacketProtocol:
"""로그인 세션으로 직접 고치기 — 저장만 한다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
ok, reason = blog_service.is_publishable_body(body)
if not ok:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = reason
return res
pid = uuid.UUID(post_id)
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.update_body(s, pid, body)],
)
res.msg = "저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다."
return res
async def approve_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol:
"""로그인 세션으로 바로 발행 — 고치지 않고 그대로, 또는 방금 edit_by_owner 로 고친 그대로 승인한다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
pid = uuid.UUID(post_id)
await self._approve_and_publish(pid, uuid.UUID(place_id))
res.msg = APPROVED
return res
async def delete_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol:
"""소프트 삭제 — 상태 제한 없이 지운다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
pid = uuid.UUID(post_id)
row = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_id(s, pid),
)
if row is None or str(row.place_id) != str(place_id):
res.result.SetResult(ErrorType.PLACE_NOT_FOUND)
return res
was_published = row.status == PostStatus.PUBLISHED.value
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.delete(s, pid)],
)
if was_published:
await self._enqueue_build(pid, uuid.UUID(place_id))
res.msg = "삭제했습니다."
return res
async def generate_range(self, user_info: UserInfo, place_id: str, start_date: date, end_date: date) -> Res_GenerateNow:
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다."""
res = Res_GenerateNow()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
if end_date < start_date:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "끝 날짜가 시작 날짜보다 앞설 수 없습니다."
return res
outcome = await blog_jobs.generate_range(place_id, start_date, end_date)
res.requested = outcome["requested"]
res.created = outcome["created"]
if res.created == res.requested:
res.msg = f"{res.created}건 만들었습니다."
elif res.created:
res.msg = f"{res.requested}일 중 {res.created}일만 채웠습니다 — 나머지는 새로 쓸 소재가 없습니다."
else:
res.msg = "이 구간엔 만들지 못했습니다 — 이미 다 있거나, 새로 쓸 소재가 없습니다."
return res
async def generate_for_date(self, user_info: UserInfo, place_id: str, target_date: date) -> Res_GenerateOne:
"""개별 생성 — 달력에서 빈 날짜 하나를 콕 집어 채운다."""
res = Res_GenerateOne()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
row = await blog_jobs.generate_one_for_date(place_id, target_date)
if row is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "이 날짜엔 만들지 못했습니다 — 이미 글이 있거나, 새로 쓸 만한 소재가 없습니다."
return res
res.post = PostData.model_validate(row)
res.msg = "만들었습니다."
return res
_SEND_NOW_MSG = {
"MAIL_NOT_CONFIGURED": "메일 발송이 설정되어 있지 않습니다.",
"NO_VALID_EMAIL": "받을 이메일 주소가 올바르지 않습니다.",
"SEND_FAILED": "메일 발송에 실패했습니다.",
}
async def send_now(self, user_info: UserInfo, place_id: str) -> Res_WebPacketProtocol:
"""빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
outcome = await blog_jobs.send_now_for_place(place_id)
if outcome["sent"]:
res.msg = "메일을 보냈습니다."
return res
reason = outcome.get("reason")
if reason == "NOTHING_DUE":
res.msg = "오늘 보낼 글이 없습니다."
return res
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = self._SEND_NOW_MSG.get(reason, "지금은 발송할 수 없습니다.")
return res
async def _owner_user_id(self, place_id):
def query(session):
return session.execute(select(places.owner_user_id).where(places.place_id == place_id))
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
return row[0] if row else None
async def _approve_and_publish(self, post_id, place_id) -> None:
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.approve(s, post_id)],
)
await self._enqueue_build(post_id, place_id)
await self._try_social_share(post_id, place_id)
async def _try_social_share(self, post_id, place_id) -> None:
"""쓰레드 연동 — 실패해도 미니블로그 승인은 이미 끝난 뒤라 예외를 밖으로 던지지 않는다."""
try:
post = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_id(s, post_id),
)
owner_user_id = await self._owner_user_id(place_id)
if post is None or owner_user_id is None or not post.body:
return
await social_service.publish_reused_text(owner_user_id, place_id, post.body)
except Exception:
LOG.w(f"[blog] post={post_id} 쓰레드 연동 실패 — 미니블로그 승인은 유지")
async def _enqueue_build(self, post_id, place_id) -> None:
"""게재 = 그 사이트 하나를 다시 굽는 것."""
owner_user_id = await self._owner_user_id(place_id)
if owner_user_id is None:
LOG.w(f"[blog] place={place_id} owner_user_id 를 못 찾아 재발행 잡을 만들지 않는다")
return
job_id, _created = await enqueue_job(
self.queue, JobType.BUILD,
{"place_id": str(place_id), "owner_user_id": str(owner_user_id),
"publish": True, "requested_by": "blog-approval"},
dedupe_key=f"build:{place_id}",
)
LOG.i(f"[blog] post={post_id} → build job={job_id}")