[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>
This commit is contained in:
Mina Choi 2026-10-01 11:33:39 +09:00
parent 89feac65c1
commit 412dc3397c
5 changed files with 85 additions and 2 deletions

View File

@ -13,6 +13,8 @@
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) |
| `blog_send_failed` | 미니블로그 승인 알림 실패 — 카톡 실패(메일로 대체) · 메일 실패 · 보낼 곳 없음. 다음 발송 성공 때 `recovery` | `blog_kakao_failed:{place_id}` · `blog_send_failed:{place_id}` |
| `booking_request_failed` | 손님 예약 요청 메일을 사장님께 못 보냄(손님 이름·연락처는 싣지 않는다). 다음 전달 성공 때 `recovery` | `booking_request_failed:{place_id}` |
| `activity` | 채널 크롤링 실패 · 사진 분석 일부 실패(`services/activity_feed.py`) | 없음(매번 보낸다) |
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건

View File

@ -21,7 +21,7 @@ from common.database.model.models import place_posts, places, sites, users
from common.enums import DBWRType, PostStatus, SiteStatus
from common.logger import LOG
from crud.post_crud import PostCRUD
from services import blog_service, kakao_link_service, mail_service, site_payload
from services import alert_service, blog_service, kakao_link_service, mail_service, site_payload
from services.external import kakao_event
from services.snapshot import build_snapshot
@ -290,6 +290,15 @@ async def _push_kakao(channel_user_key: str, post, edit_token: str) -> bool:
return False
async def _alert_send_failed(place, post, reason: str) -> None:
await alert_service.send_alert(
kind="blog_send_failed",
title=f"미니블로그 승인 알림 발송 실패 — {place.name}",
detail=f"place_id={place.place_id} post_id={post.post_id}\n{reason}",
dedupe_key=f"blog_send_failed:{place.place_id}",
)
async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 발송 → 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 DB 를 안 건드린다
(다시 시도할 길을 막지 않는다).
@ -318,18 +327,29 @@ async def _send_one(place, user, post) -> bool:
# 이 간격보다 훨씬 길다.
if await _push_kakao(kakao_key, post, edit_token):
await mark_sent()
await alert_service.resolve_alert(f"blog_kakao_failed:{place.place_id}", f"미니블로그 카톡 알림 정상화 — {place.name}")
await alert_service.resolve_alert(f"blog_send_failed:{place.place_id}", f"미니블로그 승인 알림 정상화 — {place.name}")
return True
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림이 안 나가 메일로 대체한다")
await alert_service.send_alert(
kind="blog_send_failed",
title=f"미니블로그 카톡 승인 알림 실패(메일로 대체) — {place.name}",
detail=f"place_id={place.place_id} post_id={post.post_id}",
dedupe_key=f"blog_kakao_failed:{place.place_id}",
)
if not _can_mail(place, user):
await _alert_send_failed(place, post, "보낼 곳이 없다 — 메일 미설정 또는 받는 주소 형식 오류")
return False
body = _mail_body(
place_name=place.name, post=post, user=user,
origin=_app_origin(), approve_token=token, edit_token=edit_token,
)
if not mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body):
await _alert_send_failed(place, post, "메일 발송 실패")
return False
await mark_sent()
await alert_service.resolve_alert(f"blog_send_failed:{place.place_id}", f"미니블로그 승인 알림 정상화 — {place.name}")
return True
@ -358,6 +378,7 @@ async def send_reviewed() -> int:
# 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 어느 쪽으로 보낼지는 _send_one 이 정한다
# (카톡이 연결돼 있으면 카톡, 실패했을 때만 메일 — 연결이 없으면 메일).
if not _can_mail(place, user) and await _kakao_target(user) is None:
await _alert_send_failed(place, post, "보낼 곳이 없다 — 메일 주소도 카톡 연결도 없다")
continue
if await _send_one(place, user, post):
sent += 1

View File

@ -5,12 +5,21 @@ 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
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
@ -22,6 +31,7 @@ class BookingRequestService:
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
@ -32,9 +42,11 @@ class BookingRequestService:
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:

View File

@ -1242,3 +1242,36 @@ def test_메일_본문에_액세스_토큰이_없다():
assert "eyJ" not in body # JWT 의 머리글자
longest = max(len(word) for word in body.split())
assert longest < 120, f"링크가 아직 길다: {longest}자"
async def test_send_now_mail_failure_alerts_once_and_recovers(client, db_engine, auth_headers, monkeypatch):
from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
outcome = {"ok": False}
monkeypatch.setattr(mail_service, "send", lambda **kwargs: outcome["ok"])
h = await auth_headers("bloggen-alert")
place_id = await _place(client, h, name="발송실패펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "alert@example.com", "id": "bloggen-alert"})
await _seed_post(db_engine, place_id, scheduled=date.today())
for _ in range(2):
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
assert res.json()["result"]["success"] is False
async def kinds():
async with db_engine.begin() as conn:
return [tuple(r) for r in (await conn.execute(text(
"SELECT kind, resolved_at IS NOT NULL FROM alert_outbox ORDER BY created_at"))).all()]
assert await kinds() == [("blog_send_failed", False)]
outcome["ok"] = True
assert (await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)).json()["result"]["success"] is True
assert await kinds() == [("blog_send_failed", True), ("recovery", False)]

View File

@ -99,3 +99,18 @@ async def test_without_smtp_answers_instead_of_failing(client, db_engine, owner_
assert res.status_code == 200
assert res.json()["success"] is False
assert "전화" in res.json()["message"]
async def test_mail_failure_alerts_without_guest_details(client, db_engine, owner_id, monkeypatch):
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
monkeypatch.setattr(mail_service, "send", lambda **kw: False)
place_id = await _seed(db_engine, owner_id)
res = await client.post("/v1/site/booking-request", json=_form(place_id))
assert res.json()["success"] is False
async with db_engine.begin() as conn:
rows = (await conn.execute(text("SELECT kind, title, detail FROM alert_outbox"))).all()
assert [r[0] for r in rows] == ["booking_request_failed"]
for value in ("김손님", "010-1234-5678", "guest@example.com"):
assert value not in rows[0][1] + rows[0][2]