"""미니 블로그 승인 처리. 기획: docs/MINI_BLOG.md ★ 메일 링크는 소유권 검사가 토큰 하나다. 그래서 토큰으로 할 수 있는 일을 한 건의 게재로 못 박는다 — post_id 를 바꿔 넣을 자리가 없고(토큰 해시로 글을 찾는다), 다른 API 를 부르지도 못한다. ★ 빌더 앱 로그인 화면(list_for_owner/edit_by_owner)은 반대로 세션이 신원이다 — place_id 가 그 사장님 소유인지를 매번 PlaceCRUD.get_place 로 확인한다. """ 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 from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus 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, 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 기준, 없으면 이번 달) → 날짜 경계 [시작, 다음달 시작). scheduled_date 가 타임존 없는 순수 DATE 라 KST 로 자른 뒤 다시 UTC 로 바꿀 필요가 없다.""" 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} 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 일치, 날짜 오름차순. 달력(월 단위)과 별개로 "당장 챙길 것"만 보여준다(2026-09-17, 사장님 지시).""" 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 하나로 바로 찾는다. 다른 업장 글이면(place_id 불일치) 빈 목록으로 끝낸다 — day-pass 토큰 소유자와 업장이 어긋나면 그 링크로 남의 글을 못 보게 한다.""" 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: """생성 이력 — 언제 몇 건 만들었는지(2026-09-17, 사장님 지시).""" 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 로 끝났는가. ★ BUILD 잡 하나가 그 업장의 승인분 전부를 한 번에 굽는다 — 글 단위 성공/실패가 아니라 "이 업장 재발행이 지금 막혀 있나"를 본다.""" 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: """로그인 세션으로 직접 고치기 — 저장만 한다. ★ 승인은 여기서 하지 않는다(2026-09-21, 사장님 지시: "승인되야 올라가도록 해야 한다") — 저장 후에는 이메일 승인 링크 (router/v1/site/post.py approve_page → decide) 또는 바로 아래 approve_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 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 로 고친 그대로 승인한다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 바로발행버튼으로도 발행 가능하도록"). 이메일 승인 링크와 별개의 두 번째 경로다.""" 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)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다 (2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").""" 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: """개별 생성 — 달력에서 빈 날짜 하나를 콕 집어 채운다(2026-09-17, 사장님 지시: "개별적으로 새로 만들수있게 해줘").""" 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시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다(2026-09-21, 사장님 요청). 보낼 게 없는 건 오류가 아니다 — generate_range 의 '이미 다 있거나 소재가 없다'와 같은 결의 안내로 끝낸다.""" 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: """쓰레드 연동 — 실패해도 미니블로그 승인은 이미 끝난 뒤라 예외를 밖으로 던지지 않는다(2026-09-21, DECISIONS 7-1-2 개정).""" 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: """게재 = 그 사이트 하나를 다시 굽는 것. 전체 재굽기가 아니다(docs/PUBLISH_VERSION.md). ★ owner_user_id 없이 넣으면 build_service.run_build 가 payload["owner_user_id"] 를 그대로 읽다 KeyError 로 죽는다 — 정상 발행 경로(site_service.py)는 로그인 세션에서 채우지만, 이 경로는 토큰뿐이라 place 에서 직접 찾아야 한다.""" 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}")