diff --git a/app/core/settings.py b/app/core/settings.py index 655ecc1..5851517 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 +NOTIFICATION = types.SimpleNamespace( + # NHN Cloud → DB 동기화 후 해당 code로 템플릿을 조회합니다. + 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"), +) + # External API Key Settings (등록 데스크 등) EXT_API_KEYS = { "registration_desk": env("API_KEY_REGISTRATION_DESK", default=None), 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 f12394c..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): @@ -143,13 +144,19 @@ 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"], ) + # 결제 완료 알림(알림톡 + 이메일)을 트랜잭션 커밋 후 비동기로 발송. + + 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..53f7a39 --- /dev/null +++ b/app/shop/payment_history/tasks.py @@ -0,0 +1,104 @@ +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__) + + +@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에서 채널별로 처리됩니다. + """ + + 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 + + else: + slack_logger.error("결제 완료 알림 발송 실패: 주문을 찾을 수 없습니다. order_id=%s", order_id) + return + + +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() + 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) -> 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: + # 이메일 템플릿이 아직 DB에 없는 경우 error 로그 남기기 + logger.error( + "결제 완료 이메일 발송 건너뜀: 템플릿을 찾을 수 없습니다. 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, + ) 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