[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:
parent
89feac65c1
commit
412dc3397c
@ -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 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user