카톡·메일 발송이 실패하면 로그 한 줄만 남고 글은 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>
95 lines
4.0 KiB
Python
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)
|