merge: feat/blog-mail-alert(발송 실패 Teams 알림) 을 main 에 병합

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-10-01 11:33:40 +09:00
commit 368762d453
5 changed files with 85 additions and 2 deletions

View File

@ -13,6 +13,8 @@
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` | | `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` | | `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) | | `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`) | 없음(매번 보낸다) | | `activity` | 채널 크롤링 실패 · 사진 분석 일부 실패(`services/activity_feed.py`) | 없음(매번 보낸다) |
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건 ★ **게이트 반려는 알리지 않는다.** 사장님이 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.enums import DBWRType, PostStatus, SiteStatus
from common.logger import LOG from common.logger import LOG
from crud.post_crud import PostCRUD 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.external import kakao_event
from services.snapshot import build_snapshot 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 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: async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 발송 → 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 DB 를 안 건드린다 """토큰 발급 → 발송 → 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 DB 를 안 건드린다
(다시 시도할 길을 막지 않는다). (다시 시도할 길을 막지 않는다).
@ -318,18 +327,29 @@ async def _send_one(place, user, post) -> bool:
# 이 간격보다 훨씬 길다. # 이 간격보다 훨씬 길다.
if await _push_kakao(kakao_key, post, edit_token): if await _push_kakao(kakao_key, post, edit_token):
await mark_sent() 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 return True
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림이 안 나가 메일로 대체한다") 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): if not _can_mail(place, user):
await _alert_send_failed(place, post, "보낼 곳이 없다 — 메일 미설정 또는 받는 주소 형식 오류")
return False return False
body = _mail_body( body = _mail_body(
place_name=place.name, post=post, user=user, place_name=place.name, post=post, user=user,
origin=_app_origin(), approve_token=token, edit_token=edit_token, 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): if not mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body):
await _alert_send_failed(place, post, "메일 발송 실패")
return False return False
await mark_sent() await mark_sent()
await alert_service.resolve_alert(f"blog_send_failed:{place.place_id}", f"미니블로그 승인 알림 정상화 — {place.name}")
return True return True
@ -358,6 +378,7 @@ async def send_reviewed() -> int:
# 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 어느 쪽으로 보낼지는 _send_one 이 정한다 # 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 어느 쪽으로 보낼지는 _send_one 이 정한다
# (카톡이 연결돼 있으면 카톡, 실패했을 때만 메일 — 연결이 없으면 메일). # (카톡이 연결돼 있으면 카톡, 실패했을 때만 메일 — 연결이 없으면 메일).
if not _can_mail(place, user) and await _kakao_target(user) is None: if not _can_mail(place, user) and await _kakao_target(user) is None:
await _alert_send_failed(place, post, "보낼 곳이 없다 — 메일 주소도 카톡 연결도 없다")
continue continue
if await _send_one(place, user, post): if await _send_one(place, user, post):
sent += 1 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.database.model.models import places, sites, users
from common.enums import DBWRType, SiteStatus from common.enums import DBWRType, SiteStatus
from common.logger import LOG from common.logger import LOG
from services import mail_service from services import alert_service, mail_service
NOT_AVAILABLE = "지금은 온라인 예약 요청을 받을 수 없습니다. 전화로 문의해 주세요." NOT_AVAILABLE = "지금은 온라인 예약 요청을 받을 수 없습니다. 전화로 문의해 주세요."
SENT = "예약 요청을 보냈습니다. 사장님이 확인 후 연락드립니다." 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: class BookingRequestService:
async def send(self, body): async def send(self, body):
from router.v1.site.booking_request import ResBookingRequest from router.v1.site.booking_request import ResBookingRequest
@ -22,6 +31,7 @@ class BookingRequestService:
place_name, owner_email = target place_name, owner_email = target
if not mail_service.is_configured() or not mail_service.is_valid_address(owner_email): if not mail_service.is_configured() or not mail_service.is_valid_address(owner_email):
LOG.w("[booking-request] 받는 주소나 SMTP 설정이 없어 전달하지 못했다") LOG.w("[booking-request] 받는 주소나 SMTP 설정이 없어 전달하지 못했다")
await _alert_failed(body.place_id, place_name, "받는 주소나 메일 설정이 없다")
return ResBookingRequest(success=False, message=NOT_AVAILABLE) return ResBookingRequest(success=False, message=NOT_AVAILABLE)
reply_to = body.email if body.email and mail_service.is_valid_address(body.email) else None 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, reply_to=reply_to,
) )
if not ok: if not ok:
await _alert_failed(body.place_id, place_name, "메일 발송 실패")
return ResBookingRequest(success=False, message=NOT_AVAILABLE) return ResBookingRequest(success=False, message=NOT_AVAILABLE)
LOG.i(f"[booking-request] 전달 완료 place={body.place_id}") 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) return ResBookingRequest(success=True, message=SENT)
async def _target(self, place_id) -> tuple[str, str] | None: async def _target(self, place_id) -> tuple[str, str] | None:

View File

@ -1242,3 +1242,36 @@ def test_메일_본문에_액세스_토큰이_없다():
assert "eyJ" not in body # JWT 의 머리글자 assert "eyJ" not in body # JWT 의 머리글자
longest = max(len(word) for word in body.split()) longest = max(len(word) for word in body.split())
assert longest < 120, f"링크가 아직 길다: {longest}자" 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.status_code == 200
assert res.json()["success"] is False assert res.json()["success"] is False
assert "전화" in res.json()["message"] 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]