Skip to content

Commit 5a8e943

Browse files
committed
fix: 전반적인 코드 개선 2차
1 parent d1bd0b4 commit 5a8e943

7 files changed

Lines changed: 83 additions & 29 deletions

File tree

app/admin_api/serializers/shop/products.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ class Meta:
8181
),
8282
}
8383

84+
def validate(self, attrs: dict) -> dict:
85+
# is_custom_response=True 면 패턴이 admin 계약 — 빈 답변 허용은 ".*", 비공란 강제는 ".+" 등으로 명시.
86+
is_custom_response = attrs.get("is_custom_response", getattr(self.instance, "is_custom_response", False))
87+
custom_response_pattern = attrs.get(
88+
"custom_response_pattern", getattr(self.instance, "custom_response_pattern", None)
89+
)
90+
if is_custom_response and not custom_response_pattern:
91+
raise serializers.ValidationError(
92+
{"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."}
93+
)
94+
return attrs
95+
8496

8597
class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
8698
option_groups = OptionGroupAdminSerializer(many=True, read_only=True)

app/core/const/shop_error_messages.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,6 @@ class PermissionErrorMessages:
1212
OTP_REQUIRED = "환불 승인자의 OTP 코드가 필요합니다."
1313

1414

15-
class DonationNotOrderableErrorMessages:
16-
NOT_FOUND = (
17-
"개인 후원을 시도해주셔서 감사해요! 죄송하지만 개인 후원이 아직 준비되지 않았어요...\n"
18-
"준비되면 SNS에 공지 예정이에요, 그때까지 조금만 기다려주세요!\n"
19-
"후원을 통해 PyCon 한국 준비 위원회와 함께해주셔서 정말 감사합니다!"
20-
)
21-
22-
2315
class ProductNotOrderableErrorMessages:
2416
ALREADY_ORDERED = "이미 결제한 상품입니다. 다시 장바구니에 담아주세요."
2517
NOT_ORDERABLE_TIME = "{} 상품은 현재 구매하실 수 없습니다."
@@ -98,3 +90,5 @@ class PortOneWebhookFailureMessages:
9890
UNEXPECTED_RETRIEVED_ORDER_ID = "결제 ID가 일치하지 않습니다."
9991
UNEXPECTED_PAID_PRICE = "결제 금액이 일치하지 않습니다."
10092
UNSUPPORTED_CURRENCY = "지원하지 않는 통화입니다."
93+
ILLEGAL_STATUS_TRANSITION = "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다."
94+
CANCELLED_NOT_SUPPORTED = "관리자 콘솔에서 결제 취소된 webhook 의 자동 처리는 아직 지원하지 않습니다."

app/core/external_apis/portone/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def req_cancel_payment(
155155
return self._request(
156156
method="POST",
157157
route="/payments/cancel",
158-
json={k: v for k, v in request_dto.items() if v},
158+
json={k: v for k, v in request_dto.items() if v is not None},
159159
action_desc="환불 요청",
160160
)
161161

app/shop/payment_history/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ class PaymentHistoryStatus(models.TextChoices):
1414
PaymentHistoryStatus.partial_refunded,
1515
}
1616
PURCHASED_STATUSES: set[PaymentHistoryStatus] = REFUNDABLE_STATUSES | {PaymentHistoryStatus.refunded}
17+
LEGAL_PAYMENT_STATUS_TRANSITIONS: dict[PaymentHistoryStatus, set[PaymentHistoryStatus]] = {
18+
PaymentHistoryStatus.pending: {PaymentHistoryStatus.completed},
19+
PaymentHistoryStatus.completed: {PaymentHistoryStatus.partial_refunded, PaymentHistoryStatus.refunded},
20+
PaymentHistoryStatus.partial_refunded: {PaymentHistoryStatus.partial_refunded, PaymentHistoryStatus.refunded},
21+
PaymentHistoryStatus.refunded: set(), # terminal
22+
}
23+
24+
25+
def is_legal_payment_status_transition(current: PaymentHistoryStatus, next_: PaymentHistoryStatus) -> bool:
26+
return next_ in LEGAL_PAYMENT_STATUS_TRANSITIONS.get(current, set())
1727

1828

1929
class PaymentHistoryQuerySet(BaseAbstractModelQuerySet):

app/shop/payment_history/serializers.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
from core.const.shop_error_messages import PortOneWebhookFailureMessages
44
from core.external_apis.portone.client import PortOneException, PortOneExceptionGroup, portone_client
5-
from django.db import models
5+
from django.db import models, transaction
66
from rest_framework import serializers
77
from shop.order.models import Order, OrderProductRelation, SingleProductCart
8-
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus
8+
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition
99

1010

1111
class PortOneV1PaymentStatus(models.TextChoices):
@@ -83,6 +83,11 @@ def validate_status(self, value: str) -> str:
8383
)
8484
elif value == PortOneV1WebhookRequestStatus.FAILED:
8585
raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.PURCHASE_FAILED, code="forgery")
86+
elif value == PortOneV1WebhookRequestStatus.CANCELLED:
87+
# TODO: 관리자 콘솔 취소 자동 처리는 미구현 — 우선 거부하고 운영자가 수동으로 환불 처리.
88+
raise serializers.ValidationError(
89+
detail=PortOneWebhookFailureMessages.CANCELLED_NOT_SUPPORTED, code="unsupported"
90+
)
8691
return value
8792

8893
def validate(self, data: dict) -> dict:
@@ -98,7 +103,7 @@ def validate(self, data: dict) -> dict:
98103
except (PortOneException, PortOneExceptionGroup) as e:
99104
raise serializers.ValidationError(detail=str(e), code="portone_error") from e
100105

101-
if retrieved_order_data["status"] not in (PortOneV1PaymentStatus.PAID, PortOneV1PaymentStatus.CANCELLED):
106+
if retrieved_order_data["status"] != PortOneV1PaymentStatus.PAID:
102107
raise serializers.ValidationError(
103108
detail=PortOneWebhookFailureMessages.UNEXPECTED_RETRIEVED_ORDER_STATUS, code="forgery"
104109
)
@@ -121,13 +126,18 @@ def validate(self, data: dict) -> dict:
121126

122127
return data
123128

129+
@transaction.atomic
124130
def create(self, validated_data: dict) -> PaymentHistory:
125-
# TODO: 결제 취소 (payment_serializer.validated_data["status"] == PortOneV1PaymentStatus.CANCELLED)인 경우,
126-
# cancellation_id를 확인하고 환불 내역을 저장하는 로직을 추가해야 합니다.
131+
# CANCELLED webhook (관리자 콘솔 취소) 자동 처리는 미구현 — validate_status 에서 거부됨.
127132
payment_info = PortOneV1PaymentDetailSerializer(instance=self.portone_payment_info).data
133+
order = self._lock_or_promote_order(validated_data["merchant_uid"])
128134

129-
assert (order_or_cart := self.cart_or_order) # nosec: B101
130-
order = order_or_cart.to_order() if isinstance(order_or_cart, SingleProductCart) else order_or_cart
135+
# State machine — webhook retry 등 중복/불법 전이는 거부.
136+
next_status = PaymentHistoryStatus.completed
137+
if not is_legal_payment_status_transition(order.current_status, next_status):
138+
raise serializers.ValidationError(
139+
detail=PortOneWebhookFailureMessages.ILLEGAL_STATUS_TRANSITION, code="illegal_transition"
140+
)
131141

132142
for product_rel in order.products.all():
133143
product_rel.status = OrderProductRelation.OrderProductStatus.paid
@@ -136,10 +146,26 @@ def create(self, validated_data: dict) -> PaymentHistory:
136146
return PaymentHistory.objects.create(
137147
order=order,
138148
imp_id=validated_data["imp_uid"],
139-
status=PaymentHistoryStatus.completed,
149+
status=next_status,
140150
price=payment_info["amount"],
141151
)
142152

153+
@staticmethod
154+
def _lock_or_promote_order(obj_id: str) -> Order:
155+
"""Order 가 있으면 lock 하여 반환. SingleProductCart 만 있으면 lock + to_order() 로 승격.
156+
157+
동시 webhook race 시: 첫 호출이 cart lock + to_order() commit 후, 두 번째 호출은
158+
cart 가 hard_delete 된 상태로 lock 해제됨 → Order 재조회에서 승격된 Order 발견.
159+
"""
160+
if order := Order.objects.select_for_update().filter_active().filter(id=obj_id).first():
161+
return order
162+
if cart := SingleProductCart.objects.select_for_update().filter_active().filter(id=obj_id).first():
163+
return cart.to_order()
164+
# 첫 lock 시 cart 가 다른 webhook 에 의해 promote 된 경우 — Order 재조회.
165+
if order := Order.objects.select_for_update().filter_active().filter(id=obj_id).first():
166+
return order
167+
raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.ORDER_NOT_FOUND, code="forgery")
168+
143169

144170
class PortOneV1WebhookResponseSerializer(serializers.Serializer):
145171
status = serializers.CharField(default="success", read_only=True)

app/shop/product/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import typing
66

77
from core.models import BaseAbstractModel
8+
from django.core.exceptions import ValidationError
89
from django.db import models
910
from django.db.models.manager import BaseManager
1011
from simple_history.models import HistoricalRecords
@@ -219,6 +220,14 @@ class Meta:
219220
def __str__(self) -> str:
220221
return f"[{self.product.name}] {self.name}"
221222

223+
def clean(self) -> None:
224+
# is_custom_response=True 시 패턴이 admin 계약 — 빈 답변 허용은 ".*", 비공란 강제는 ".+" 등으로 명시.
225+
if self.is_custom_response and not self.custom_response_pattern:
226+
raise ValidationError(
227+
{"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."}
228+
)
229+
super().clean()
230+
222231
def is_group_stock_available(self) -> bool:
223232
"""해당 옵션 그룹의 재고가 있는지 확인합니다."""
224233
if (

app/shop/serializers/cart_validation.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def validate(self, data: dict) -> dict:
7979
if tag.leftover_stock is not None and tag.leftover_stock <= 0:
8080
raise serializers.ValidationError(TagNotOrderableErrorMessages.SOLDOUT.format(tag.name))
8181

82-
if tag.max_quantity_per_user:
82+
if tag.max_quantity_per_user > 0: # 0 = 무제한 sentinel
8383
match self.validation_mode:
8484
case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART:
8585
user_tagproduct_taken_count = (
@@ -221,7 +221,7 @@ def validate_product_option(self, option: Option | None) -> Option | None:
221221
)
222222

223223
# 옵션의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능
224-
if option.max_quantity_per_user:
224+
if option.max_quantity_per_user > 0: # 0 = 무제한 sentinel
225225
match self.validation_mode:
226226
case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART:
227227
user_option_taken_count = (
@@ -375,7 +375,7 @@ def validate_product(self, product: Product) -> Product:
375375
)
376376

377377
# 상품의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 담거나 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능
378-
if product.max_quantity_per_user:
378+
if product.max_quantity_per_user > 0: # 0 = 무제한 sentinel
379379
match self.validation_mode:
380380
case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART:
381381
user_product_taken_count = (
@@ -556,15 +556,7 @@ def create( # type: ignore[override]
556556
order_product_rel = super().create(validated_data)
557557
assert (single_product_cart := order_product_rel.single_product_cart) # nosec: B101
558558

559-
# 단일 상품 장바구니에 고객 정보를 저장합니다.
560-
if customer_info := CustomerInfo.objects.filter(single_product_cart=single_product_cart).first():
561-
customer_info_serializer = CustomerInfoCheckSerializer(
562-
instance=customer_info, data=validated_data["customer_info"]
563-
)
564-
customer_info_serializer.is_valid(raise_exception=True)
565-
customer_info_serializer.save()
566-
else:
567-
CustomerInfo.objects.create(**validated_data["customer_info"], single_product_cart=single_product_cart)
559+
CustomerInfo.objects.create(**validated_data["customer_info"], single_product_cart=single_product_cart)
568560

569561
return order_product_rel
570562

@@ -583,6 +575,17 @@ class CartOrderableCheckSerializer(serializers.Serializer):
583575
class Meta:
584576
fields = ("cart",)
585577

578+
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
579+
super().__init__(*args, **kwargs)
580+
# 타인의 미결제 cart 로 결제 흐름 트리거를 방어 — request.user 의 cart 로만 lookup 한정.
581+
request = self.context.get("request")
582+
user = request.user if request is not None else None
583+
self.fields["cart"].queryset = (
584+
Order.objects.filter_has_no_payment_histories().filter(user=user)
585+
if isinstance(user, UserExt)
586+
else Order.objects.none()
587+
)
588+
586589
def validate(self, data: dict) -> dict:
587590
cart: Order = data["cart"]
588591

0 commit comments

Comments
 (0)