충돌은 이 브랜치의 주석 정리 커밋(11d30bb)과 main 의 기능 변경이 겹친 백엔드 9개 파일 —
main 쪽을 그대로 썼다(기능이 main 에만 있다). AGENTS.md 는 양쪽 링크를 모두 두고,
DEVLOG 는 요약본 위에 main 의 09-28 에이전트 항목 셋을 붙였다.
site eslint 통과 · vitest 123 passed · frontend tsc·eslint 통과 · 백엔드 py_compile 통과
(백엔드 pytest 는 이 기계에 테스트 DB 계정이 없어 실행하지 못함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.7 KiB
Python
198 lines
8.7 KiB
Python
"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면."""
|
|
import html
|
|
from datetime import date
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
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 import blog_service
|
|
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"])
|
|
|
|
_REDIRECT_DELAY_SEC = 5
|
|
|
|
_PAGE = """<!doctype html><html lang="ko"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
|
|
{redirect}<title>{title}</title><style>
|
|
body{{margin:0;background:#f6f5f1;color:#1b1a15;font:17px/1.7 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif}}
|
|
.wrap{{max-width:34rem;margin:0 auto;padding:40px 20px}}
|
|
h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}}
|
|
.meta{{color:#6b7269;font-size:14px}} a{{color:#1b1a15}}
|
|
</style></head><body><div class="wrap">{content}</div></body></html>"""
|
|
|
|
|
|
def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse:
|
|
# redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 + slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라 escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
|
|
redirect = (
|
|
f'<meta http-equiv="refresh" content="{_REDIRECT_DELAY_SEC};url={html.escape(redirect_url, quote=True)}">'
|
|
if redirect_url else ""
|
|
)
|
|
return HTMLResponse(_PAGE.format(title=title, content=content, redirect=redirect))
|
|
|
|
|
|
def _expired_page() -> HTMLResponse:
|
|
return _page(
|
|
"처리할 수 없는 링크입니다",
|
|
"<h1>처리할 수 없는 링크입니다</h1><p class='meta'>이미 처리했거나 기한이 지난 링크입니다.</p>",
|
|
)
|
|
|
|
|
|
@router.get(path="/edit", summary="수정하기 — 일회용 코드를 세션으로 바꿔 편집 화면으로 보낸다")
|
|
async def edit_redirect(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
|
|
"""메일의 '고쳐서 올리려면'.
|
|
|
|
★ 액세스 토큰은 **쿼리가 아니라 프래그먼트**로 넘긴다 — 프래그먼트는 서버 로그와 Referer 에
|
|
남지 않는다. 예전처럼 쿼리에 실으면 주소가 500자가 되는 것보다, 메일 전달 한 번이
|
|
자정까지의 권한 양도가 되는 쪽이 더 나빴다(services/post_service.open_editor)."""
|
|
result = await service.open_editor(t)
|
|
if not result["success"]:
|
|
return _expired_page()
|
|
target = (
|
|
f"{blog_service.app_origin()}/blog"
|
|
f"?placeId={result['place_id']}&postId={result['post_id']}"
|
|
f"#auto={result['auto']}"
|
|
)
|
|
# 303 — 이 주소는 다시 쓸 수 없으니 브라우저가 되돌아오지 않게 한다.
|
|
return RedirectResponse(target, status_code=303, headers={
|
|
"Cache-Control": "no-store", "Referrer-Policy": "no-referrer",
|
|
})
|
|
|
|
|
|
@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()
|
|
redirect_url = result.get("redirect_url")
|
|
# 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
|
|
extra = (
|
|
f"<p class='meta'>{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. "
|
|
f"바로 가려면 <a href='{html.escape(redirect_url, quote=True)}'>여기</a>를 눌러주세요.</p>"
|
|
if redirect_url else ""
|
|
)
|
|
return _page(
|
|
"올렸습니다",
|
|
f"<h1>{result['message']}</h1><p class='meta'>사이트에 반영되기까지 몇 분 걸립니다.</p>{extra}",
|
|
redirect_url=redirect_url,
|
|
)
|
|
|
|
|
|
@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))
|