o2o-site-AEO/solution/backend/services/booking_request_service.py
Mina Choi 412dc3397c [feat] solution/backend: 미니블로그 승인 알림·예약 요청 메일 발송 실패를 Teams 로 — 정상화되면 recovery
카톡·메일 발송이 실패하면 로그 한 줄만 남고 글은 REVIEWED 로 계속 머물렀다. 손님 예약 요청은
사장님께 못 가도 아무도 몰랐다.

- blog_jobs: 카톡 실패(메일 대체) · 메일 실패 · 보낼 곳 없음 → blog_send_failed, 사업장별 dedupe, 성공 시 resolve
- booking_request_service: 전달 실패 → booking_request_failed, 손님 이름·연락처는 싣지 않는다
- docs/ALERTS.md: 두 kind 추가

테스트 2건 추가. 블로그·예약·알림 93 passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:33:39 +09:00

95 lines
4.0 KiB
Python

"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다."""
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 alert_service, mail_service
NOT_AVAILABLE = "지금은 온라인 예약 요청을 받을 수 없습니다. 전화로 문의해 주세요."
SENT = "예약 요청을 보냈습니다. 사장님이 확인 후 연락드립니다."
async def _alert_failed(place_id, place_name: str, reason: str) -> None:
await alert_service.send_alert(
kind="booking_request_failed",
title=f"손님 예약 요청을 사장님께 전달하지 못함 — {place_name}",
detail=f"place_id={place_id}\n{reason}",
dedupe_key=f"booking_request_failed:{place_id}",
)
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 설정이 없어 전달하지 못했다")
await _alert_failed(body.place_id, place_name, "받는 주소나 메일 설정이 없다")
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:
await _alert_failed(body.place_id, place_name, "메일 발송 실패")
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
LOG.i(f"[booking-request] 전달 완료 place={body.place_id}")
await alert_service.resolve_alert(f"booking_request_failed:{body.place_id}", f"예약 요청 메일 정상화 — {place_name}")
return ResBookingRequest(success=True, message=SENT)
async def _target(self, place_id) -> tuple[str, str] | 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)