사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도 이후에 연결". 승인만 하고 실제로 어디 올라갔는지 못 찾는 걸 줄인다. 재발행(BUILD 잡)은 몇 분 걸리므로 5초 뒤 반영을 보장하진 않지만, 어디로 가면 되는지는 바로 알려준다. 자동 이동을 못 믿어도 되게 같은 주소를 안내 문구의 링크로도 남긴다. - post_service._blog_url: 발행된 사이트가 있으면 그 미니블로그 자리(#blog) 주소, 없으면 None — 호출부가 자동 이동 없이 확인 문구만 보여준다 - router/v1/site/post.py: <meta http-equiv="refresh"> + 안내 링크 추가. redirect_url은 서버가 site_payload.publish_url()로 만드는 고정 오리진+slugify 통과 값이라 사용자 입력은 아니지만, HTML 속성에 꽂는 자리라 html.escape 적용 (자동 보안 리뷰 지적 반영) - 신규 테스트 2건(발행 사이트 있을 때/없을 때), 관련 스위트 전체 49 passed 로컬 solution-backend·solution-worker 재빌드해 반영 확인함
404 lines
19 KiB
Python
404 lines
19 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, 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 기준, 없으면 이번 달) → 날짜 경계 [시작, 다음달 시작). 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,
|
|
"redirect_url": await self._blog_url(place_id),
|
|
}
|
|
|
|
async def _blog_url(self, place_id) -> str | None:
|
|
"""이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리. 승인 확인 화면이 몇 초
|
|
뒤 여기로 자동 연결한다(2026-09-22, 사장님 지시) — 사장님이 승인만 하고 실제로
|
|
어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 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 일치, 날짜 오름차순. 달력(월 단위)과 별개로
|
|
"당장 챙길 것"만 보여준다(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}")
|