"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면. 기획: docs/MINI_BLOG.md ★ /approve 는 로그인이 없다. 링크에 실린 토큰 하나가 신원이고, 누르는(GET) 순간 바로 승인된다(2026-09-17, 사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). ★★ 이건 메일 클라이언트의 링크 미리 열기(아웃룩 안전 링크 스캔 등)에 그대로 노출된다는 뜻이다 — 예전에는 이걸 막으려고 GET=확인 화면 / POST=승인 확정으로 나눴었다. 사장님이 그 위험을 알고도 즉시 승인을 택했다. ★ "수정하기" 는 반대로 로그인 흐름을 탄다 — 메일에 그날 자정(KST)까지만 사는 접근 토큰을 실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번 글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다. ★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면. ★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve (이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션, "바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 — 승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다. """ from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, Query from fastapi.responses import HTMLResponse from common.models.gmodel import Res_WebPacketProtocol, UserInfo from router.v1.site.protocol import ( Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts, ) from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.post_service import PostService router = APIRouter(prefix="/v1/site/post", tags=["Site"]) owner_router = APIRouter(prefix="/v1/place/{place_id}/post", tags=["Site"]) _PAGE = """ {title}
{content}
""" def _page(title: str, content: str) -> HTMLResponse: return HTMLResponse(_PAGE.format(title=title, content=content)) def _expired_page() -> HTMLResponse: return _page( "처리할 수 없는 링크입니다", "

처리할 수 없는 링크입니다

이미 처리했거나 기한이 지난 링크입니다.

", ) @router.get(path="/approve", response_class=HTMLResponse, summary="승인 확정 — 누르는 즉시 게재 큐에 넣는다") async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()): result = await service.decide(t, skip=False) if not result["success"]: return _expired_page() return _page("올렸습니다", f"

{result['message']}

사이트에 반영되기까지 몇 분 걸립니다.

") @owner_router.get(path="", response_model=Res_MyPosts, summary="이번 달(또는 고른 달) 생성된 글 목록") async def list_my_posts( place_id: UUID, month: str | None = Query(default=None, pattern=r"^\d{4}-\d{2}$", description="YYYY-MM, 기본값 이번 달"), service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.list_for_owner(user_info, str(place_id), month)) @owner_router.get(path="/upcoming", response_model=Res_MyPosts, summary="상단 카로셀 — 오늘부터 N일치, 날짜순") async def list_upcoming_posts( place_id: UUID, days: int = Query(default=7, ge=1, le=30), service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.list_upcoming(user_info, str(place_id), days)) @owner_router.get(path="/history", response_model=Res_GenerationHistory, summary="생성 이력 — 언제 몇 건 만들었는지") async def get_generation_history( place_id: UUID, service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.generation_history(user_info, str(place_id))) @owner_router.get(path="/{post_id}", response_model=Res_MyPosts, summary="글 하나 — 메일 수정 링크(자동 로그인)가 쓴다") async def get_my_post( place_id: UUID, post_id: UUID, service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id))) @owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로") async def edit_my_post( place_id: UUID, post_id: UUID, req: Req_EditPost, service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body)) @owner_router.post( path="/{post_id}/approve", response_model=Res_WebPacketProtocol, summary="바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인", ) async def approve_my_post( place_id: UUID, post_id: UUID, service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id))) @owner_router.delete(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다") async def delete_my_post( place_id: UUID, post_id: UUID, service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.delete_by_owner(user_info, str(place_id), str(post_id))) @owner_router.post( path="/send-now", response_model=Res_WebPacketProtocol, summary="승인 알림보내기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다", ) async def send_my_posts_now( place_id: UUID, service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.send_now(user_info, str(place_id))) @owner_router.post( path="/generate", response_model=Res_GenerateNow, summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다", ) async def generate_my_posts( place_id: UUID, start: date = Query(description="구간 시작일"), end: date = Query(description="구간 끝일"), service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.generate_range(user_info, str(place_id), start, end)) @owner_router.post( path="/generate-one", response_model=Res_GenerateOne, summary="개별 생성 — 달력에서 빈 날짜 하나만 콕 집어 채운다", ) async def generate_my_post_for_date( place_id: UUID, target_date: date = Query(alias="date"), service: PostService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): return RemoveNoneResponse(await service.generate_for_date(user_info, str(place_id), target_date))