o2o-site-AEO/solution/backend/services/booking_request_service.py
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(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에서 보정).
2026-09-18 09:03:18 +09:00

87 lines
3.6 KiB
Python

"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다.
★ 저장하지 않는다. place_id 로 받는 사람만 찾고, 나머지는 전부 메일 본문으로 나간다.
★ 발행된 사이트의 업장만 받는다 — place_id 를 손으로 바꿔 아무 업장에나 메일을 쏘는 길을 막는다.
"""
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, sites, users
from common.enums import DBWRType, SiteStatus
from common.logger import LOG
from services import mail_service
NOT_AVAILABLE = "지금은 온라인 예약 요청을 받을 수 없습니다. 전화로 문의해 주세요."
SENT = "예약 요청을 보냈습니다. 사장님이 확인 후 연락드립니다."
class BookingRequestService:
async def send(self, body):
from router.v1.site.booking_request import ResBookingRequest
target = await self._target(body.place_id)
if not target:
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
place_name, owner_email = target
if not mail_service.is_configured() or not mail_service.is_valid_address(owner_email):
LOG.w("[booking-request] 받는 주소나 SMTP 설정이 없어 전달하지 못했다")
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
reply_to = body.email if body.email and mail_service.is_valid_address(body.email) else None
ok = mail_service.send(
to=owner_email,
subject=f"[예약 요청] {place_name}{body.name}",
text=_body(place_name, body),
reply_to=reply_to,
)
if not ok:
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
LOG.i(f"[booking-request] 전달 완료 place={body.place_id}")
return ResBookingRequest(success=True, message=SENT)
async def _target(self, place_id) -> tuple[str, str] | None:
"""(상호명, 사장님 이메일). 발행된 사이트가 없으면 None."""
def query(session):
return session.execute(
select(places.name, users.email)
.join(sites, sites.place_id == places.place_id)
.join(users, users.user_id == places.owner_user_id)
.where(
places.place_id == place_id,
places.deleted == False, # noqa: E712
sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
)
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 or not row[1]:
return None
return str(row[0] or ""), str(row[1])
def _body(place_name: str, body) -> str:
lines = [
f"{place_name} 예약 요청이 도착했습니다.",
"",
f"성함 {body.name}",
f"연락처 {body.phone}",
]
if body.email:
lines.append(f"이메일 {body.email}")
if body.stay:
lines.append(f"일정 {body.stay}")
if body.guests:
lines.append(f"인원 {body.guests}")
if body.message:
lines += ["", "요청사항", body.message.strip()]
lines += [
"",
"— 이 메일은 발행 사이트의 예약 요청 폼에서 자동으로 보냈습니다.",
" 예약이 확정된 것은 아니며, 손님에게 직접 연락하셔야 합니다.",
]
return "\n".join(lines)