인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.
- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
(line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
빌드 스크립트 추가(다른 세션 작업)
테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
303 lines
14 KiB
Python
303 lines
14 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
|
|
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 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)
|
|
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:
|
|
"""로그인 세션으로 직접 고치기 — 메일이 아직 안 나간 REVIEWED 글도 여기서 바로 승인된다."""
|
|
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)],
|
|
)
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
|
|
)
|
|
await self._enqueue_build(pid, uuid.UUID(place_id))
|
|
res.msg = APPROVED
|
|
return res
|
|
|
|
async def approve_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol:
|
|
"""로그인 세션으로 바로 발행 — 고치지 않고 그대로. edit_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
|
|
|
|
pid = uuid.UUID(post_id)
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
|
|
)
|
|
await self._enqueue_build(pid, uuid.UUID(place_id))
|
|
res.msg = APPROVED
|
|
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
|
|
|
|
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 _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}")
|