From 412dc3397ce6d5e2ae3fd520ebd85407f5a28c66 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 1 Oct 2026 11:33:39 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution/backend:=20=EB=AF=B8=EB=8B=88?= =?UTF-8?q?=EB=B8=94=EB=A1=9C=EA=B7=B8=20=EC=8A=B9=EC=9D=B8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=C2=B7=EC=98=88=EC=95=BD=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=A5=BC=20Teams=20=EB=A1=9C=20=E2=80=94=20=EC=A0=95=EC=83=81?= =?UTF-8?q?=ED=99=94=EB=90=98=EB=A9=B4=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카톡·메일 발송이 실패하면 로그 한 줄만 남고 글은 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 --- docs/ALERTS.md | 2 ++ solution/backend/services/blog_jobs.py | 23 ++++++++++++- .../services/booking_request_service.py | 14 +++++++- solution/backend/tests/test_blog_owner.py | 33 +++++++++++++++++++ .../backend/tests/test_booking_request.py | 15 +++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/ALERTS.md b/docs/ALERTS.md index f062796..57ecccd 100644 --- a/docs/ALERTS.md +++ b/docs/ALERTS.md @@ -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 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건 diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index b902bd7..132c7b4 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -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 diff --git a/solution/backend/services/booking_request_service.py b/solution/backend/services/booking_request_service.py index 05408ba..f193b64 100644 --- a/solution/backend/services/booking_request_service.py +++ b/solution/backend/services/booking_request_service.py @@ -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: diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index b9462b3..60fa008 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -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)] diff --git a/solution/backend/tests/test_booking_request.py b/solution/backend/tests/test_booking_request.py index cc5ea54..040879c 100644 --- a/solution/backend/tests/test_booking_request.py +++ b/solution/backend/tests/test_booking_request.py @@ -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]