From 462e22123ddf916bf3bcd547e6ae6473d9166ec3 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Sun, 17 May 2026 00:23:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=B4=ED=9B=84=20=EC=95=8C=EB=A6=BC=ED=86=A1=20?= =?UTF-8?q?+=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=ED=95=98?= =?UTF-8?q?=EB=8A=94=20celery=20task=20=EC=88=98=ED=96=89=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(=EC=B4=88=EC=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/settings.py | 10 +++ app/shop/payment_history/serializers.py | 9 ++- app/shop/payment_history/tasks.py | 98 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 app/shop/payment_history/tasks.py diff --git a/app/core/settings.py b/app/core/settings.py index 655ecc1..e7730e6 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -456,6 +456,16 @@ refund_authorizer_secret_key=env("REFUND_AUTHORIZER_SECRET_KEY", default="local_refund_authorizer_secret_key"), ) +# Notification Settings +# 각 템플릿 코드는 NHN Cloud Console에 등록된 코드와 일치해야 합니다. +# NHN Cloud → DB 동기화 후 해당 code로 템플릿을 조회합니다. +NOTIFICATION = types.SimpleNamespace( + # TODO: NHN Cloud에 등록된 결제 완료 알림톡 템플릿 코드로 교체 + payment_completed_alimtalk_template_code=env("PAYMENT_COMPLETED_ALIMTALK_TEMPLATE_CODE", default=""), + # TODO: 결제 완료 이메일 템플릿 생성 후 해당 코드로 교체 + payment_completed_email_template_code=env("PAYMENT_COMPLETED_EMAIL_TEMPLATE_CODE", default=""), +) + # External API Key Settings (등록 데스크 등) EXT_API_KEYS = { "registration_desk": env("API_KEY_REGISTRATION_DESK", default=None), diff --git a/app/shop/payment_history/serializers.py b/app/shop/payment_history/serializers.py index f12394c..3b54862 100644 --- a/app/shop/payment_history/serializers.py +++ b/app/shop/payment_history/serializers.py @@ -143,13 +143,20 @@ def create(self, validated_data: dict) -> PaymentHistory: product_rel.status = OrderProductRelation.OrderProductStatus.paid product_rel.save() - return PaymentHistory.objects.create( + payment_history = PaymentHistory.objects.create( order=order, imp_id=validated_data["imp_uid"], status=next_status, price=payment_info["amount"], ) + # 결제 완료 알림(알림톡 + 이메일)을 트랜잭션 커밋 후 비동기로 발송. + from shop.payment_history.tasks import send_payment_completed_notifications + + transaction.on_commit(lambda: send_payment_completed_notifications.delay(str(order.id))) + + return payment_history + @staticmethod def _lock_or_promote_order(obj_id: str) -> Order: """Order 가 있으면 lock 하여 반환. SingleProductCart 만 있으면 lock + to_order() 로 승격. diff --git a/app/shop/payment_history/tasks.py b/app/shop/payment_history/tasks.py new file mode 100644 index 0000000..8f8929c --- /dev/null +++ b/app/shop/payment_history/tasks.py @@ -0,0 +1,98 @@ +import logging + +from celery import shared_task + +slack_logger = logging.getLogger("slack_logger") +logger = logging.getLogger(__name__) + + +@shared_task(ignore_result=True) +def send_payment_completed_notifications(order_id: str) -> None: + """결제 완료 시 알림톡 + 이메일 자동 발송 (비동기 Celery task). + + - customer_info가 없는 주문은 발송을 건너뛰고 slack_logger로 누락 사실을 기록합니다. + - 알림톡과 이메일은 독립적으로 시도되며, 한 채널 실패가 다른 채널 발송을 막지 않습니다. + - 실제 외부 API 호출은 send_notification_to_recipient task에서 채널별로 처리됩니다. + """ + from django.conf import settings + from shop.order.models import Order + + order = Order.objects.filter_active().select_related("customer_info").filter(id=order_id).first() + + if order is None: + slack_logger.error("결제 완료 알림 발송 실패: 주문을 찾을 수 없습니다. order_id=%s", order_id) + return + + customer_info = getattr(order, "customer_info", None) + if customer_info is None: + slack_logger.error( + "결제 완료 알림 발송 누락: customer_info가 없는 주문입니다. order_id=%s", + order_id, + ) + return + + # TODO: 알림톡/이메일 템플릿 변수 확정 후 context 업데이트 필요. + # 템플릿에서 사용하는 #{변수명} / {{ 변수명 }} 목록에 맞게 값을 추가하세요. + context = { + "phone": customer_info.phone, + "email": customer_info.email, + } + + _send_alimtalk(order_id, customer_info.phone, context, settings) + _send_email(order_id, customer_info.email, context, settings) + + +def _send_alimtalk(order_id: str, recipient_phone: str, context: dict, settings) -> None: + from notification.models import ( + NHNCloudKakaoAlimTalkNotificationHistory, + NHNCloudKakaoAlimTalkNotificationTemplate, + ) + + try: + template_code = settings.NOTIFICATION.payment_completed_alimtalk_template_code + template = NHNCloudKakaoAlimTalkNotificationTemplate.objects.filter_active().filter(code=template_code).first() + if template is None: + slack_logger.error( + "결제 완료 알림톡 발송 실패: 템플릿을 찾을 수 없습니다. template_code=%s order_id=%s", + template_code, + order_id, + ) + return + + history = NHNCloudKakaoAlimTalkNotificationHistory.objects.create_for_recipients( + template=template, + recipients=[{"recipient": recipient_phone, "context": context}], + ) + history.send() + except Exception: + slack_logger.exception( + "결제 완료 알림톡 발송 중 예외 발생. order_id=%s", + order_id, + ) + + +def _send_email(order_id: str, recipient_email: str, context: dict, settings) -> None: + from notification.models import EmailNotificationHistory, EmailNotificationTemplate + + try: + template_code = settings.NOTIFICATION.payment_completed_email_template_code + template = EmailNotificationTemplate.objects.filter_active().filter(code=template_code).first() + if template is None: + # 이메일 템플릿은 아직 미생성 상태일 수 있으므로 warning 수준으로 기록. + logger.warning( + "결제 완료 이메일 발송 건너뜀: 템플릿을 찾을 수 없습니다. template_code=%s order_id=%s", + template_code, + order_id, + ) + return + + history = EmailNotificationHistory.objects.create_for_recipients( + template=template, + recipients=[{"recipient": recipient_email, "context": context}], + ) + history.send() + except Exception: + slack_logger.exception( + "결제 완료 이메일 발송 중 예외 발생. order_id=%s", + order_id, + ) From 9021bec7556124eb7655d1d0d525974c26dfe102 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Mon, 18 May 2026 00:14:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20lint=20error=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/settings.py | 8 +- ...3_seed_payment_completed_email_template.py | 51 +++ .../templates/payment_completed.html | 328 ++++++++++++++++++ app/shop/payment_history/serializers.py | 2 +- app/shop/payment_history/tasks.py | 72 ++-- app/shop/payment_history/test/__init__.py | 0 app/shop/payment_history/test/tasks_test.py | 200 +++++++++++ 7 files changed, 622 insertions(+), 39 deletions(-) create mode 100644 app/notification/migrations/0003_seed_payment_completed_email_template.py create mode 100644 app/notification/templates/payment_completed.html create mode 100644 app/shop/payment_history/test/__init__.py create mode 100644 app/shop/payment_history/test/tasks_test.py diff --git a/app/core/settings.py b/app/core/settings.py index e7730e6..d494334 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -457,13 +457,11 @@ ) # Notification Settings -# 각 템플릿 코드는 NHN Cloud Console에 등록된 코드와 일치해야 합니다. -# NHN Cloud → DB 동기화 후 해당 code로 템플릿을 조회합니다. NOTIFICATION = types.SimpleNamespace( - # TODO: NHN Cloud에 등록된 결제 완료 알림톡 템플릿 코드로 교체 + # NHN Cloud → DB 동기화 후 해당 code로 템플릿을 조회합니다. payment_completed_alimtalk_template_code=env("PAYMENT_COMPLETED_ALIMTALK_TEMPLATE_CODE", default=""), - # TODO: 결제 완료 이메일 템플릿 생성 후 해당 코드로 교체 - payment_completed_email_template_code=env("PAYMENT_COMPLETED_EMAIL_TEMPLATE_CODE", default=""), + # DB에 등록된 결제 완료 이메일 템플릿 코드로 교체 완료 + payment_completed_email_template_code=env.str("PAYMENT_COMPLETED_EMAIL_TEMPLATE_CODE", default="payment_completed"), ) # External API Key Settings (등록 데스크 등) diff --git a/app/notification/migrations/0003_seed_payment_completed_email_template.py b/app/notification/migrations/0003_seed_payment_completed_email_template.py new file mode 100644 index 0000000..2e9aebc --- /dev/null +++ b/app/notification/migrations/0003_seed_payment_completed_email_template.py @@ -0,0 +1,51 @@ +import json +from pathlib import Path + +from django.conf import settings +from django.db import migrations + +# DB에 등록할 결제 완료 이메일 템플릿 코드로 교체 완료 (payment_completed.html) +# 만일 추후 변경 시 settings.NOTIFICATION.payment_completed_email_template_code 및 환경변수도 함께 수정 필요 +_TEMPLATE_CODE = "payment_completed" + +_EMAIL_SUBJECT = "파이콘 한국 티켓 결제가 완료되었습니다!" + +_HTML_TEMPLATE_PATH = Path(__file__).parent.parent / "templates" / "payment_completed.html" + + +def seed_payment_completed_email_template(apps, schema_editor): + EmailNotificationTemplate = apps.get_model("notification", "EmailNotificationTemplate") + EmailNotificationTemplate.objects.get_or_create( + code=_TEMPLATE_CODE, + defaults={ + "title": "결제 완료 이메일", + # migration 실행 시점의 환경변수(EMAIL_HOST_USER)를 발신 주소로 사용. + # 값이 비어있으면 이메일 발송 시 오류가 발생하므로 배포 전 EMAIL_HOST_USER 설정 필요. + "sent_from": settings.EMAIL_HOST_USER, + "data": json.dumps( + { + "title": _EMAIL_SUBJECT, + "body": _HTML_TEMPLATE_PATH.read_text(encoding="utf-8"), + }, + ensure_ascii=False, + ), + }, + ) + + +def reverse_seed_payment_completed_email_template(apps, schema_editor): + EmailNotificationTemplate = apps.get_model("notification", "EmailNotificationTemplate") + EmailNotificationTemplate.objects.filter(code=_TEMPLATE_CODE).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("notification", "0002_emailnotificationhistorysentto_failure_reason_and_more"), + ] + + operations = [ + migrations.RunPython( + seed_payment_completed_email_template, # 실행할 로직 + reverse_seed_payment_completed_email_template, # 실행할 로직에서 failure 발생 시 되돌릴 역방향 로직 + ), + ] diff --git a/app/notification/templates/payment_completed.html b/app/notification/templates/payment_completed.html new file mode 100644 index 0000000..3d01480 --- /dev/null +++ b/app/notification/templates/payment_completed.html @@ -0,0 +1,328 @@ + + + + + + + 파이콘 한국 스토어 결제 완료 안내 | PyCon Korea Store — Order Confirmation + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ Python Korea +

+ 파이콘 한국 스토어 | PyCon Korea Store +

+

+ 파이콘 한국과 함께해 주셔서 감사합니다. +

+

+ Thank you for your order. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ 주문 정보 | ORDER INFO +

+
+ 주문 명 (Order name) + + {{ order_name }} +
+ 결제 일시 (Paid at) + + {{ first_paid_at }} +
+ 결제 금액 (Paid amount) + + {{ first_paid_price }} +
+

+ 주문자 정보 | CUSTOMER INFO +

+
+ 이름 (Name) + + {{ customer_name }} +
+ 이메일 (Email) + + {{ customer_email }} +
+ 연락처 (Phone) + + {{ customer_phone }} +
+
+

+ 주문 상세 확인, 영수증, 환불 관련 안내 | ORDER & REFUND + INFO +

+

+ 주문 상세 내역과 영수증, 환불 관련 안내는 + 파이콘 한국 홈페이지에서 확인해주세요. +

+

+ For more details, please visit the + PyCon Korea homepage. +

+ + + + +
+ 파이콘 한국 홈페이지 | PyCon Korea Homepage +
+
+

+ 본 메일은 발신 전용입니다. 문의는 + pycon@pycon.kr + 으로 부탁드립니다. +

+

+ This is a send-only email. For inquiries, please contact + pycon@pycon.kr. +

+

+ © 파이썬 한국 사용자 모임 (Python Korea) +

+
+
+ + diff --git a/app/shop/payment_history/serializers.py b/app/shop/payment_history/serializers.py index 3b54862..d8a3d51 100644 --- a/app/shop/payment_history/serializers.py +++ b/app/shop/payment_history/serializers.py @@ -6,6 +6,7 @@ from rest_framework import serializers from shop.order.models import Order, OrderProductRelation, SingleProductCart from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition +from shop.payment_history.tasks import send_payment_completed_notifications class PortOneV1PaymentStatus(models.TextChoices): @@ -151,7 +152,6 @@ def create(self, validated_data: dict) -> PaymentHistory: ) # 결제 완료 알림(알림톡 + 이메일)을 트랜잭션 커밋 후 비동기로 발송. - from shop.payment_history.tasks import send_payment_completed_notifications transaction.on_commit(lambda: send_payment_completed_notifications.delay(str(order.id))) diff --git a/app/shop/payment_history/tasks.py b/app/shop/payment_history/tasks.py index 8f8929c..53f7a39 100644 --- a/app/shop/payment_history/tasks.py +++ b/app/shop/payment_history/tasks.py @@ -1,6 +1,14 @@ import logging from celery import shared_task +from django.conf import settings +from notification.models import ( + EmailNotificationHistory, + EmailNotificationTemplate, + NHNCloudKakaoAlimTalkNotificationHistory, + NHNCloudKakaoAlimTalkNotificationTemplate, +) +from shop.order.models import Order slack_logger = logging.getLogger("slack_logger") logger = logging.getLogger(__name__) @@ -14,40 +22,40 @@ def send_payment_completed_notifications(order_id: str) -> None: - 알림톡과 이메일은 독립적으로 시도되며, 한 채널 실패가 다른 채널 발송을 막지 않습니다. - 실제 외부 API 호출은 send_notification_to_recipient task에서 채널별로 처리됩니다. """ - from django.conf import settings - from shop.order.models import Order - order = Order.objects.filter_active().select_related("customer_info").filter(id=order_id).first() + if ( + order := Order.objects.filter_active() + .select_related("customer_info") + .prefetch_related("products", Order.prefetchs["_payment_histories_by_latest"]) + .filter(id=order_id) + .first() + ) is not None: + if (customer_info := getattr(order, "customer_info", None)) is not None: + context = { + "order_name": order.name, + "first_paid_at": order.first_paid_at, + "first_paid_price": order.first_paid_price, + "customer_name": customer_info.name, + "customer_phone": customer_info.phone, + "customer_email": customer_info.email, + } + + _send_alimtalk(order_id, customer_info.phone, context) + _send_email(order_id, customer_info.email, context) + + else: + slack_logger.error( + "결제 완료 알림 발송 누락: customer_info가 없는 주문입니다. order_id=%s", + order_id, + ) + return - if order is None: + else: slack_logger.error("결제 완료 알림 발송 실패: 주문을 찾을 수 없습니다. order_id=%s", order_id) return - customer_info = getattr(order, "customer_info", None) - if customer_info is None: - slack_logger.error( - "결제 완료 알림 발송 누락: customer_info가 없는 주문입니다. order_id=%s", - order_id, - ) - return - - # TODO: 알림톡/이메일 템플릿 변수 확정 후 context 업데이트 필요. - # 템플릿에서 사용하는 #{변수명} / {{ 변수명 }} 목록에 맞게 값을 추가하세요. - context = { - "phone": customer_info.phone, - "email": customer_info.email, - } - - _send_alimtalk(order_id, customer_info.phone, context, settings) - _send_email(order_id, customer_info.email, context, settings) - - -def _send_alimtalk(order_id: str, recipient_phone: str, context: dict, settings) -> None: - from notification.models import ( - NHNCloudKakaoAlimTalkNotificationHistory, - NHNCloudKakaoAlimTalkNotificationTemplate, - ) +def _send_alimtalk(order_id: str, recipient_phone: str, context: dict) -> None: try: template_code = settings.NOTIFICATION.payment_completed_alimtalk_template_code template = NHNCloudKakaoAlimTalkNotificationTemplate.objects.filter_active().filter(code=template_code).first() @@ -71,15 +79,13 @@ def _send_alimtalk(order_id: str, recipient_phone: str, context: dict, settings) ) -def _send_email(order_id: str, recipient_email: str, context: dict, settings) -> None: - from notification.models import EmailNotificationHistory, EmailNotificationTemplate - +def _send_email(order_id: str, recipient_email: str, context: dict) -> None: try: template_code = settings.NOTIFICATION.payment_completed_email_template_code template = EmailNotificationTemplate.objects.filter_active().filter(code=template_code).first() if template is None: - # 이메일 템플릿은 아직 미생성 상태일 수 있으므로 warning 수준으로 기록. - logger.warning( + # 이메일 템플릿이 아직 DB에 없는 경우 error 로그 남기기 + logger.error( "결제 완료 이메일 발송 건너뜀: 템플릿을 찾을 수 없습니다. template_code=%s order_id=%s", template_code, order_id, diff --git a/app/shop/payment_history/test/__init__.py b/app/shop/payment_history/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/payment_history/test/tasks_test.py b/app/shop/payment_history/test/tasks_test.py new file mode 100644 index 0000000..ffc913e --- /dev/null +++ b/app/shop/payment_history/test/tasks_test.py @@ -0,0 +1,200 @@ +import logging +import types +from unittest.mock import patch + +import pytest +from core.models import BaseAbstractModelQuerySet +from notification.models import ( + EmailNotificationHistory, + EmailNotificationTemplate, + NHNCloudKakaoAlimTalkNotificationHistory, + NHNCloudKakaoAlimTalkNotificationTemplate, +) +from notification.models.base import NotificationStatus +from shop.order.models import CustomerInfo, Order +from shop.payment_history.tasks import send_payment_completed_notifications +from user.models import UserExt + +_EMAIL_TEMPLATE_CODE = "payment_completed_email" +_ALIMTALK_TEMPLATE_CODE = "payment_completed_alimtalk" + +_EMAIL_LOGGER = "shop.payment_history.tasks" +_SLACK_LOGGER = "slack_logger" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def user(db): + return UserExt.objects.create_user(username="buyer", email="buyer@example.com", password="x") # nosec B106 + + +@pytest.fixture +def order(user): + return Order.objects.create(user=user, name="파이콘 한국 2026 티켓") + + +@pytest.fixture +def order_with_customer(order): + CustomerInfo.objects.create( + order=order, + name="홍길동", + phone="01012345678", + email="customer@example.com", + ) + return order + + +@pytest.fixture +def email_template(db): + return EmailNotificationTemplate.objects.create( + code=_EMAIL_TEMPLATE_CODE, + title="결제 완료 이메일", + sent_from="noreply@pycon.kr", + data='{"title":"결제가 완료되었습니다","body":"안녕하세요 {{ name }}님, {{ phone }}, {{ email }}"}', + ) + + +@pytest.fixture +def alimtalk_template(db): + # 알림톡 템플릿은 NHN Cloud 동기화 전용이라 .create()가 차단됨 — bulk_create로 우회. + template = NHNCloudKakaoAlimTalkNotificationTemplate( + code=_ALIMTALK_TEMPLATE_CODE, + title="결제 완료 알림톡", + sent_from="sender_key_abc", + data='{"templateContent":"안녕하세요 #{name}님","buttons":[]}', + ) + [created] = BaseAbstractModelQuerySet(model=NHNCloudKakaoAlimTalkNotificationTemplate).bulk_create([template]) + return created + + +@pytest.fixture +def override_email_setting(settings): + settings.NOTIFICATION = types.SimpleNamespace( + payment_completed_alimtalk_template_code=_ALIMTALK_TEMPLATE_CODE, + payment_completed_email_template_code=_EMAIL_TEMPLATE_CODE, + ) + + +@pytest.fixture +def override_email_setting_with_error(settings): + settings.NOTIFICATION = types.SimpleNamespace( + payment_completed_alimtalk_template_code="", payment_completed_email_template_code="nonexistent" + ) + + +# --------------------------------------------------------------------------- +# Happy path — 이메일 +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_creates_email_history_when_template_exists(order_with_customer, email_template, override_email_setting): + send_payment_completed_notifications(str(order_with_customer.id)) + + history = EmailNotificationHistory.objects.filter_active().get() + sent_to = history.sent_to_list.get() + assert sent_to.recipient == "customer@example.com" + assert sent_to.context == {"name": "홍길동", "phone": "01012345678", "email": "customer@example.com"} + assert sent_to.status == NotificationStatus.CREATED + + +# --------------------------------------------------------------------------- +# Happy path — 알림톡 +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_creates_alimtalk_history_when_template_exists(order_with_customer, alimtalk_template, override_email_setting): + send_payment_completed_notifications(str(order_with_customer.id)) + + history = NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active().get() + sent_to = history.sent_to_list.get() + assert sent_to.recipient == "01012345678" + assert sent_to.status == NotificationStatus.CREATED + + +# --------------------------------------------------------------------------- +# 주문 / customer_info 누락 +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_logs_error_and_creates_no_history_when_order_not_found(caplog): + with caplog.at_level(logging.ERROR, logger=_SLACK_LOGGER): + send_payment_completed_notifications("00000000-0000-0000-0000-000000000000") + + assert any("주문을 찾을 수 없습니다" in r.getMessage() for r in caplog.records) + assert EmailNotificationHistory.objects.count() == 0 + assert NHNCloudKakaoAlimTalkNotificationHistory.objects.count() == 0 + + +@pytest.mark.django_db +def test_logs_error_and_creates_no_history_when_customer_info_missing(order, caplog): + with caplog.at_level(logging.ERROR, logger=_SLACK_LOGGER): + send_payment_completed_notifications(str(order.id)) + + assert any("customer_info가 없는" in r.getMessage() for r in caplog.records) + assert EmailNotificationHistory.objects.count() == 0 + assert NHNCloudKakaoAlimTalkNotificationHistory.objects.count() == 0 + + +# --------------------------------------------------------------------------- +# 템플릿 누락 +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_logs_warning_and_creates_no_history_when_email_template_not_found( + order_with_customer, caplog, override_email_setting_with_error +): + with caplog.at_level(logging.WARNING, logger=_EMAIL_LOGGER): + send_payment_completed_notifications(str(order_with_customer.id)) + + assert any("이메일 발송 건너뜀" in r.getMessage() for r in caplog.records) + assert EmailNotificationHistory.objects.count() == 0 + + +@pytest.mark.django_db +def test_logs_error_and_creates_no_history_when_alimtalk_template_not_found( + order_with_customer, caplog, override_email_setting_with_error +): + with caplog.at_level(logging.ERROR, logger=_SLACK_LOGGER): + send_payment_completed_notifications(str(order_with_customer.id)) + + assert any("알림톡 발송 실패" in r.getMessage() for r in caplog.records) + assert NHNCloudKakaoAlimTalkNotificationHistory.objects.count() == 0 + + +# --------------------------------------------------------------------------- +# 채널 독립성 — 한 채널 실패가 다른 채널을 막지 않아야 함 +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_alimtalk_failure_does_not_prevent_email( + order_with_customer, email_template, alimtalk_template, override_email_setting +): + with patch( + "notification.models.NHNCloudKakaoAlimTalkNotificationHistory.objects.create_for_recipients", + side_effect=Exception("alimtalk boom"), + ): + send_payment_completed_notifications(str(order_with_customer.id)) + + assert EmailNotificationHistory.objects.filter_active().count() == 1 + + +@pytest.mark.django_db +def test_email_failure_does_not_prevent_alimtalk( + order_with_customer, email_template, alimtalk_template, override_email_setting +): + with patch( + "notification.models.EmailNotificationHistory.objects.create_for_recipients", + side_effect=Exception("email boom"), + ): + send_payment_completed_notifications(str(order_with_customer.id)) + + assert NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active().count() == 1 From 6fc8d86c2a7a3d71ce9878a29b65f19064810f34 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Tue, 19 May 2026 00:05:30 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EA=B2=80=EC=88=98=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=EB=90=9C=20=EC=95=8C=EB=A6=BC=ED=86=A1=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20code=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/settings.py b/app/core/settings.py index d494334..5851517 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -459,7 +459,9 @@ # Notification Settings NOTIFICATION = types.SimpleNamespace( # NHN Cloud → DB 동기화 후 해당 code로 템플릿을 조회합니다. - payment_completed_alimtalk_template_code=env("PAYMENT_COMPLETED_ALIMTALK_TEMPLATE_CODE", default=""), + payment_completed_alimtalk_template_code=env.str( + "PAYMENT_COMPLETED_ALIMTALK_TEMPLATE_CODE", default="pycon_2026_paid" + ), # DB에 등록된 결제 완료 이메일 템플릿 코드로 교체 완료 payment_completed_email_template_code=env.str("PAYMENT_COMPLETED_EMAIL_TEMPLATE_CODE", default="payment_completed"), )