From 80a565b0899607ff664017ed887d23e26642b097 Mon Sep 17 00:00:00 2001 From: jjunier Date: Mon, 25 May 2026 23:59:31 +0900 Subject: [PATCH] feat(events): add backend domain event logging [D2C-45] - add safe backend domain event recording helper - record order, payment, coupon, review, cart domain events - keep domain event logging failures from blocking API responses - add server logging for domain event recording failures --- .../api/backend/services/cart_item_service.py | 79 +++++++++++++++- apps/api/backend/services/cart_service.py | 67 +++++++------ apps/api/backend/services/coupon_service.py | 46 +++++++++ .../api/backend/services/event_log_service.py | 25 +++++ apps/api/backend/services/order_service.py | 45 +++++++++ apps/api/backend/services/payment_service.py | 94 +++++++++++++++++++ apps/api/backend/services/review_service.py | 45 ++++++++- 7 files changed, 370 insertions(+), 31 deletions(-) diff --git a/apps/api/backend/services/cart_item_service.py b/apps/api/backend/services/cart_item_service.py index f00e583..56fd833 100644 --- a/apps/api/backend/services/cart_item_service.py +++ b/apps/api/backend/services/cart_item_service.py @@ -9,6 +9,8 @@ CartItemCreateRequest, CartItemQuantityUpdateRequest, ) +from backend.services.event_log_service import record_domain_event_safely + MAX_CART_ITEM_QUANTITY = 99 @@ -16,6 +18,7 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, cart_query = text(""" SELECT cart_id, + user_id, cart_status FROM carts WHERE cart_id = :cart_id @@ -157,6 +160,24 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update cart item", ) + + record_domain_event_safely( + event_name="cart_item_quantity_changed", + user_id=cart["user_id"], + entity_type="cart", + entity_id=updated_item["cart_id"], + properties={ + "cart_id": updated_item["cart_id"], + "cart_item_id": updated_item["cart_item_id"], + "product_id": updated_item["product_id"], + "previous_quantity": existing_item["quantity"], + "next_quantity": updated_item["quantity"], + "quantity_delta": payload.quantity, + "unit_price": updated_item["unit_price"], + "currency": updated_item["currency"], + "change_source": "add_item_to_cart", + }, + ) return { "cart_item_id": updated_item["cart_item_id"], @@ -185,6 +206,21 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to add item to cart", ) + + record_domain_event_safely( + event_name="cart_item_added", + user_id=cart["user_id"], + entity_type="cart", + entity_id=created_item["cart_id"], + properties={ + "cart_id": created_item["cart_id"], + "cart_item_id": created_item["cart_item_id"], + "product_id": created_item["product_id"], + "quantity": created_item["quantity"], + "unit_price": created_item["unit_price"], + "currency": created_item["currency"], + }, + ) return { "cart_item_id": created_item["cart_item_id"], @@ -201,7 +237,8 @@ def add_item_to_cart(cart_id: UUID, payload: CartItemCreateRequest) -> dict[str, def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: cart_query = text(""" SELECT - cart_id + cart_id, + user_id FROM carts WHERE cart_id = :cart_id AND cart_status = 'active' @@ -214,7 +251,11 @@ def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: AND cart_id = :cart_id RETURNING cart_item_id, - cart_id + cart_id, + product_id, + quantity, + unit_price, + currency """) with engine.begin() as connection: @@ -242,6 +283,21 @@ def remove_item_from_cart(cart_id: UUID, cart_item_id: UUID) -> dict[str, Any]: status_code=status.HTTP_404_NOT_FOUND, detail="Cart item not found", ) + + record_domain_event_safely( + event_name="cart_item_removed", + user_id=cart["user_id"], + entity_type="cart", + entity_id=deleted_item["cart_id"], + properties={ + "cart_id": deleted_item["cart_id"], + "cart_item_id": deleted_item["cart_item_id"], + "product_id": deleted_item["product_id"], + "quantity": deleted_item["quantity"], + "unit_price": deleted_item["unit_price"], + "currency": deleted_item["currency"], + }, + ) return { "cart_item_id": deleted_item["cart_item_id"], @@ -257,6 +313,7 @@ def update_cart_item_quantity( cart_query = text(""" SELECT cart_id, + user_id, cart_status FROM carts WHERE cart_id = :cart_id @@ -269,6 +326,7 @@ def update_cart_item_quantity( cart_item_id, cart_id, product_id, + quantity, unit_price, currency FROM cart_items @@ -335,6 +393,23 @@ def update_cart_item_quantity( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update cart item quantity", ) + + record_domain_event_safely( + event_name="cart_item_quantity_changed", + user_id=cart["user_id"], + entity_type="cart", + entity_id=updated_item["cart_id"], + properties={ + "cart_id": updated_item["cart_id"], + "cart_item_id": updated_item["cart_item_id"], + "product_id": updated_item["product_id"], + "previous_quantity": item["quantity"], + "next_quantity": updated_item["quantity"], + "unit_price": updated_item["unit_price"], + "currency": updated_item["currency"], + "change_source": "update_cart_item_quantity", + }, + ) return { "cart_item_id": updated_item["cart_item_id"], diff --git a/apps/api/backend/services/cart_service.py b/apps/api/backend/services/cart_service.py index f7e8efd..d4ff12b 100644 --- a/apps/api/backend/services/cart_service.py +++ b/apps/api/backend/services/cart_service.py @@ -7,6 +7,7 @@ from backend.db.connection import engine from backend.schemas.cart import CartCreateRequest +from backend.services.event_log_service import record_domain_event_safely def create_or_get_active_cart(payload: CartCreateRequest) -> dict[str, Any]: @@ -49,6 +50,8 @@ def create_or_get_active_cart(payload: CartCreateRequest) -> dict[str, Any]: checked_out_at """) + is_created = False + with engine.begin() as connection: existing = connection.execute( select_query, @@ -56,35 +59,43 @@ def create_or_get_active_cart(payload: CartCreateRequest) -> dict[str, Any]: ).mappings().first() if existing is not None: - return { - "cart_id": existing["cart_id"], - "user_id": existing["user_id"], - "cart_status": existing["cart_status"], - "created_at": existing["created_at"], - "updated_at": existing["updated_at"], - "checked_out_at": existing["checked_out_at"], - } - - created = connection.execute( - insert_query, - {"user_id": payload.user_id}, - ).mappings().first() - - if created is None: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create cart", - ) - - return { - "cart_id": created["cart_id"], - "user_id": created["user_id"], - "cart_status": created["cart_status"], - "created_at": created["created_at"], - "updated_at": created["updated_at"], - "checked_out_at": created["checked_out_at"], - } + cart = existing + else: + created = connection.execute( + insert_query, + {"user_id": payload.user_id}, + ).mappings().first() + + if created is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create cart", + ) + + cart = created + is_created = True + + if is_created: + record_domain_event_safely( + event_name="cart_created", + user_id=cart["user_id"], + entity_type="cart", + entity_id=cart["cart_id"], + properties={ + "cart_id": cart["cart_id"], + "cart_status": cart["cart_status"], + "created_at": cart["created_at"], + }, + ) + return { + "cart_id": cart["cart_id"], + "user_id": cart["user_id"], + "cart_status": cart["cart_status"], + "created_at": cart["created_at"], + "updated_at": cart["updated_at"], + "checked_out_at": cart["checked_out_at"], + } def get_cart_detail(cart_id: UUID) -> dict[str, Any]: cart_query = text(""" diff --git a/apps/api/backend/services/coupon_service.py b/apps/api/backend/services/coupon_service.py index 57c23e9..e05c798 100644 --- a/apps/api/backend/services/coupon_service.py +++ b/apps/api/backend/services/coupon_service.py @@ -1,3 +1,4 @@ +import logging from decimal import Decimal, ROUND_HALF_UP from typing import Any from uuid import UUID @@ -7,8 +8,34 @@ from backend.db.connection import engine from backend.schemas.coupon_apply import CouponApplyRequest +from backend.services.event_log_service import record_event +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) + def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str, Any]: cart_query = text(""" SELECT @@ -136,6 +163,25 @@ def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str final_amount = total_amount - discount_amount + record_domain_event_safely( + event_name="coupon_applied", + user_id=cart["user_id"], + entity_type="coupon", + entity_id=coupon["coupon_id"], + properties={ + "cart_id": cart_id, + "coupon_id": coupon["coupon_id"], + "coupon_name": coupon["coupon_name"], + "coupon_type": coupon["coupon_type"], + "discount_value": discount_value, + "minimum_order_amount": minimum_order_amount, + "total_amount": total_amount, + "discount_amount": discount_amount, + "final_amount": final_amount, + "currency": currency, + }, + ) + return { "cart_id": cart_id, "coupon": { diff --git a/apps/api/backend/services/event_log_service.py b/apps/api/backend/services/event_log_service.py index f2ebf6b..f139032 100644 --- a/apps/api/backend/services/event_log_service.py +++ b/apps/api/backend/services/event_log_service.py @@ -1,4 +1,5 @@ import json +import logging from typing import Any from uuid import UUID, uuid4 @@ -11,6 +12,7 @@ ALLOWED_EVENT_TYPES = {"user_behavior", "domain_event", "system_event"} ALLOWED_SOURCES = {"frontend", "backend", "script"} +logger = logging.getLogger(__name__) def record_event( @@ -108,6 +110,29 @@ def record_event( } +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) + + def create_event_log(payload: EventLogCreateRequest) -> dict[str, Any]: return record_event( event_name=payload.event_name, diff --git a/apps/api/backend/services/order_service.py b/apps/api/backend/services/order_service.py index 8c7c388..c6b510a 100644 --- a/apps/api/backend/services/order_service.py +++ b/apps/api/backend/services/order_service.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime, timedelta, timezone from decimal import Decimal, ROUND_HALF_UP from typing import Any @@ -8,10 +9,35 @@ from backend.db.connection import engine from backend.schemas.order import OrderCreateRequest +from backend.services.event_log_service import record_event KST = timezone(timedelta(hours=9)) +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) def now_kst_naive() -> datetime: return datetime.now(KST).replace(tzinfo=None) @@ -309,6 +335,25 @@ def create_order_from_cart(payload: OrderCreateRequest) -> dict[str, Any]: } ) + record_domain_event_safely( + event_name="order_created", + user_id=created_order["user_id"], + entity_type="order", + entity_id=created_order["order_id"], + properties={ + "order_id": created_order["order_id"], + "cart_id": created_order["cart_id"], + "order_status": created_order["order_status"], + "subtotal_amount": created_order["subtotal_amount"], + "discount_amount": created_order["discount_amount"], + "total_amount": created_order["total_amount"], + "currency": created_order["currency"], + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "item_count": len(created_items), + }, + ) + return { "order_id": created_order["order_id"], "user_id": created_order["user_id"], diff --git a/apps/api/backend/services/payment_service.py b/apps/api/backend/services/payment_service.py index ca7ae9c..9b48649 100644 --- a/apps/api/backend/services/payment_service.py +++ b/apps/api/backend/services/payment_service.py @@ -1,16 +1,43 @@ +import logging from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Any +from uuid import UUID from fastapi import HTTPException, status from sqlalchemy import text from backend.db.connection import engine from backend.schemas.payment import PaymentSimulationRequest +from backend.services.event_log_service import record_event KST = timezone(timedelta(hours=9)) +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) def now_kst_naive() -> datetime: return datetime.now(KST).replace(tzinfo=None) @@ -19,7 +46,9 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: order_query = text(""" SELECT order_id, + user_id, cart_id, + coupon_id, order_status, total_amount, currency @@ -181,6 +210,71 @@ def simulate_payment(payload: PaymentSimulationRequest) -> dict[str, Any]: detail="Failed to create payment", ) + if created_payment["payment_status"] == "paid": + record_domain_event_safely( + event_name="payment_succeeded", + user_id=order["user_id"], + entity_type="payment", + entity_id=created_payment["payment_id"], + properties={ + "payment_id": created_payment["payment_id"], + "order_id": created_payment["order_id"], + "cart_id": order["cart_id"], + "payment_method": created_payment["payment_method"], + "payment_status": created_payment["payment_status"], + "requested_amount": requested_amount, + "approved_amount": created_payment["paid_amount"], + "currency": created_payment["currency"], + "pg_provider": created_payment["pg_provider"], + "transaction_id": created_payment["transaction_id"], + }, + ) + + record_domain_event_safely( + event_name="cart_checked_out", + user_id=order["user_id"], + entity_type="cart", + entity_id=order["cart_id"], + properties={ + "cart_id": order["cart_id"], + "order_id": created_payment["order_id"], + "payment_id": created_payment["payment_id"], + "cart_status": "checked_out", + }, + ) + + if order["coupon_id"] is not None: + record_domain_event_safely( + event_name="coupon_used", + user_id=order["user_id"], + entity_type="coupon", + entity_id=order["coupon_id"], + properties={ + "coupon_id": order["coupon_id"], + "order_id": created_payment["order_id"], + "payment_id": created_payment["payment_id"], + "cart_id": order["cart_id"], + }, + ) + else: + record_domain_event_safely( + event_name="payment_failed", + user_id=order["user_id"], + entity_type="payment", + entity_id=created_payment["payment_id"], + properties={ + "payment_id": created_payment["payment_id"], + "order_id": created_payment["order_id"], + "cart_id": order["cart_id"], + "payment_method": created_payment["payment_method"], + "payment_status": created_payment["payment_status"], + "requested_amount": requested_amount, + "approved_amount": created_payment["paid_amount"], + "currency": created_payment["currency"], + "failure_code": created_payment["failure_code"], + }, + ) + return { "payment_id": created_payment["payment_id"], "order_id": created_payment["order_id"], diff --git a/apps/api/backend/services/review_service.py b/apps/api/backend/services/review_service.py index ace3540..7f92d92 100644 --- a/apps/api/backend/services/review_service.py +++ b/apps/api/backend/services/review_service.py @@ -1,3 +1,4 @@ +import logging from typing import Any from decimal import Decimal from uuid import UUID @@ -11,7 +12,33 @@ ReviewDeleteRequest, ReviewUpdateRequest, ) - +from backend.services.event_log_service import record_event + + +logger = logging.getLogger(__name__) + + +def record_domain_event_safely( + *, + event_name: str, + user_id: UUID | None, + entity_type: str | None, + entity_id: UUID | None, + properties: dict[str, Any], +) -> None: + try: + record_event( + event_name=event_name, + event_type="domain_event", + source="backend", + user_id=user_id, + session_id=None, + entity_type=entity_type, + entity_id=entity_id, + properties=properties, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("Failed to record domain event: %s", event_name) def create_review(payload: ReviewCreateRequest) -> dict[str, Any]: user_query = text(""" @@ -158,6 +185,22 @@ def create_review(payload: ReviewCreateRequest) -> dict[str, Any]: detail="Failed to create review", ) + record_domain_event_safely( + event_name="review_created", + user_id=created_review["user_id"], + entity_type="review", + entity_id=created_review["review_id"], + properties={ + "review_id": created_review["review_id"], + "product_id": created_review["product_id"], + "order_item_id": created_review["order_item_id"], + "rating": created_review["rating"], + "review_status": created_review["review_status"], + "has_review_title": bool(created_review["review_title"]), + "has_review_content": bool(created_review["review_content"]), + }, + ) + return { "review_id": created_review["review_id"], "user_id": created_review["user_id"],