o2o-site-AEO/solution/backend/services/post_service.py
김성경 47da2f29b3 [feat] solution/backend,docs: 미니블로그 승인 → 쓰레드 자동 게재, 쓰레드 초안 생성 OpenAI 전환
사장님 지시: "쓰레드에 연동되어 있으면 같이 업로드 되는 기능". 미니블로그의 두
승인 경로(이메일 GET 토큰, 로그인 "바로 발행")를 공통 메서드로 묶고, 그 끝에서
쓰레드 연동을 시도한다. 미니블로그 승인 자체가 발화 동의로 취급되므로 쓰레드
쪽 별도 승인은 묻지 않는다(DECISIONS 7-1-2 개정, 문구를 그대로 재사용하는
경우에 한정). 겸사겸사 쓰레드 초안 생성(generate_social_post)이 LLM_PROVIDER
를 안 타고 Gemini 를 직접 호출하던 것도 다른 생성 함수와 같은 추상화로 맞췄다.

- blog_jobs._published_places: sites.domain IS NOT NULL 조건 추가(쓰레드 기준과 통일)
- social_service.publish_reused_text: 연동 없음/게시 비활성/domain 미확정이면 스킵,
  정상이면 APPROVED 삽입 + run_post(job_type=9) enqueue — 새 게시 로직은 안 만든다
- post_service: decide/approve_by_owner → _approve_and_publish 로 공통화,
  _try_social_share 는 실패를 전부 삼켜 미니블로그 승인을 막지 않는다
- gemini_text.generate_social_post: services.llm.provider.active() 로 전환,
  Gemini 전용 import 제거
- DECISIONS.md 7-1-2, MINI_BLOG.md 5·8절, SOCIAL.md 갱신

test_blog_owner.py·test_social.py 다수 추가/수정, 관련 스위트 전체 PASS
2026-09-22 08:33:40 +09:00

375 lines
18 KiB
Python

"""미니 블로그 승인 처리. 기획: 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}")