Skip to content

Commit 40ee04d

Browse files
committed
security: 결제 진행 중 장바구니 변경을 막음
1 parent 6b9e09a commit 40ee04d

23 files changed

Lines changed: 1059 additions & 169 deletions

app/core/const/shop_error_messages.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from django.db import models
2+
from rest_framework.exceptions import ValidationError
3+
4+
15
class CriticalErrorMessages:
26
INVALID_LOGIC = "발생하면 안 되는 오류가 발생했습니다. PyCon 한국 준비 위원회에 문의해주세요.\n{}"
37

@@ -80,13 +84,19 @@ class OptionGroupNotModifiableErrorMessages:
8084
RESPONSE_NOT_MODIFIABLE = "해당 옵션은 수정할 수 없습니다. PyCon 한국 준비 위원회에 문의해주세요."
8185

8286

83-
class PortOneWebhookFailureMessages:
84-
ORDER_NOT_FOUND = "주문 정보가 존재하지 않습니다."
85-
PURCHASE_FAILED = "결제에 실패했습니다."
86-
VIRTUAL_ACCOUNT_NOT_SUPPORTED = "가상계좌 결제는 지원하지 않습니다."
87-
UNEXPECTED_RETRIEVED_ORDER_STATUS = "예상한 결제 상태가 아닙니다."
88-
UNEXPECTED_RETRIEVED_ORDER_ID = "결제 ID가 일치하지 않습니다."
89-
UNEXPECTED_PAID_PRICE = "결제 금액이 일치하지 않습니다."
90-
UNSUPPORTED_CURRENCY = "지원하지 않는 통화입니다."
91-
ILLEGAL_STATUS_TRANSITION = "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다."
92-
CANCELLED_NOT_SUPPORTED = "관리자 콘솔에서 결제 취소된 webhook 의 자동 처리는 아직 지원하지 않습니다."
87+
class PortOneWebhookFailureCode(models.TextChoices):
88+
ORDER_NOT_FOUND = "ORDER_NOT_FOUND", "주문 정보가 존재하지 않습니다."
89+
PURCHASE_FAILED = "PURCHASE_FAILED", "결제에 실패했습니다."
90+
VIRTUAL_ACCOUNT_NOT_SUPPORTED = "VIRTUAL_ACCOUNT_NOT_SUPPORTED", "가상계좌 결제는 지원하지 않습니다."
91+
UNEXPECTED_RETRIEVED_ORDER_STATUS = "UNEXPECTED_RETRIEVED_ORDER_STATUS", "예상한 결제 상태가 아닙니다."
92+
UNEXPECTED_RETRIEVED_ORDER_ID = "UNEXPECTED_RETRIEVED_ORDER_ID", "결제 ID가 일치하지 않습니다."
93+
UNEXPECTED_PAID_PRICE = "UNEXPECTED_PAID_PRICE", "결제 금액이 일치하지 않습니다."
94+
UNSUPPORTED_CURRENCY = "UNSUPPORTED_CURRENCY", "지원하지 않는 통화입니다."
95+
ILLEGAL_STATUS_TRANSITION = "ILLEGAL_STATUS_TRANSITION", "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다."
96+
CANCELLED_NOT_SUPPORTED = (
97+
"CANCELLED_NOT_SUPPORTED",
98+
"관리자 콘솔에서 결제 취소된 webhook 의 자동 처리는 아직 지원하지 않습니다.",
99+
)
100+
101+
def as_error(self) -> ValidationError:
102+
return ValidationError(detail=self.label, code=self.value)

app/core/external_apis/portone/client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,23 @@ def find_payment_info(self, imp_uid: str) -> dict:
149149
raise PortOneException(f"결제 정보를 찾을 수 없습니다. {imp_uid=}")
150150

151151
def req_cancel_payment(
152-
self, merchant_id: str, refund_request_price: int, current_leftover_price: int, reason: str | None = None
152+
self,
153+
imp_id: str,
154+
refund_request_price: int | float,
155+
current_leftover_price: int | float,
156+
reason: str | None = None,
153157
) -> dict:
154158
"""결제 환불 요청
155159
Args:
156-
merchant_id (str): 결제 번호
157-
refund_request_price (int): 환불 요청 금액
158-
current_leftover_price (int): 현재 환불 가능한 남은 금액
160+
imp_id (str): 포트원 거래고유번호
161+
refund_request_price (int | float): 환불 요청 금액
162+
current_leftover_price (int | float): 현재 환불 가능한 남은 금액
159163
reason (str | None): 환불 사유
160164
Returns:
161165
dict: 결제 사전 등록 또는 수정 응답
162166
"""
163167
request_dto = {
164-
"merchant_uid": merchant_id,
168+
"imp_uid": imp_id,
165169
"amount": refund_request_price,
166170
"checksum": current_leftover_price,
167171
"reason": reason,

app/shop/conftest.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def single_product_cart(customer_user, product) -> SingleProductCart:
197197
return cart
198198

199199

200-
OrderStatus = Literal["empty", "cart", "completed", "refunded", "partial_refunded"]
200+
OrderStatus = Literal["empty", "cart", "prepared", "completed", "refunded", "partial_refunded"]
201201

202202

203203
@pytest.fixture
@@ -208,6 +208,7 @@ def order_factory(customer_user, product, donation_product):
208208
status:
209209
- ``"empty"``: OPR / CustomerInfo 없는 빈 Order (donation 인자 무시)
210210
- ``"cart"``: PH 없음, OPR pending
211+
- ``"prepared"``: ``"cart"`` + 결제 준비 snapshot/hash 저장
211212
- ``"completed"``: PH completed + OPR paid
212213
- ``"refunded"``: 전액 환불 — PH refunded(price=0) 추가 + OPR refunded
213214
- ``"partial_refunded"``: 부분 환불 — PH partial_refunded 추가
@@ -232,6 +233,9 @@ def make(*, status: OrderStatus = "cart", donation: int = 0) -> Order:
232233

233234
if status == "cart":
234235
return order
236+
if status == "prepared":
237+
order.prepare_payment()
238+
return order
235239

236240
# status >= 'completed' — OPR paid + PH completed.
237241
order.products.update(status=OrderProductRelation.OrderProductStatus.paid)
@@ -304,6 +308,7 @@ def mock_portone_req_cancel_payment():
304308
환불 호출은 반환값을 호출자가 사용하지 않으므로 (호출 인자/횟수만 검증), 명시 세팅 없이도 None 반환 허용.
305309
"""
306310
with patch.object(portone_client, "req_cancel_payment") as mocked:
311+
mocked.return_value = None
307312
yield mocked
308313

309314

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Generated by Django 6.0.4 on 2026-05-23 11:52
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [("order", "0002_migrate_from_legacy")]
8+
operations = [
9+
migrations.AddField(
10+
model_name="historicalorder",
11+
name="prepared_cart_hash",
12+
field=models.CharField(blank=True, max_length=16, null=True),
13+
),
14+
migrations.AddField(
15+
model_name="historicalorder",
16+
name="prepared_cart_snapshot",
17+
field=models.JSONField(blank=True, null=True),
18+
),
19+
migrations.AddField(
20+
model_name="historicalsingleproductcart",
21+
name="prepared_cart_hash",
22+
field=models.CharField(blank=True, max_length=16, null=True),
23+
),
24+
migrations.AddField(
25+
model_name="historicalsingleproductcart",
26+
name="prepared_cart_snapshot",
27+
field=models.JSONField(blank=True, null=True),
28+
),
29+
migrations.AddField(
30+
model_name="order",
31+
name="prepared_cart_hash",
32+
field=models.CharField(blank=True, max_length=16, null=True),
33+
),
34+
migrations.AddField(
35+
model_name="order",
36+
name="prepared_cart_snapshot",
37+
field=models.JSONField(blank=True, null=True),
38+
),
39+
migrations.AddField(
40+
model_name="singleproductcart",
41+
name="prepared_cart_hash",
42+
field=models.CharField(blank=True, max_length=16, null=True),
43+
),
44+
migrations.AddField(
45+
model_name="singleproductcart",
46+
name="prepared_cart_snapshot",
47+
field=models.JSONField(blank=True, null=True),
48+
),
49+
]

app/shop/order/models.py

Lines changed: 168 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@
22

33
import datetime
44
import functools
5+
import json
56
import typing
7+
from base64 import urlsafe_b64encode
8+
from collections.abc import Iterable
9+
from contextlib import suppress
10+
from hashlib import sha256
11+
from hmac import new as hmac_new
612
from urllib.parse import urljoin
13+
from uuid import UUID, uuid4
714

15+
import shortuuid
816
from core.const.shop_error_messages import NotRefundableErrorMessages
917
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
1018
from core.scancode_mixin import ScanCodeMixin
@@ -17,9 +25,97 @@
1725
from simple_history.models import HistoricalRecords
1826

1927
UserModel = get_user_model()
28+
PAYMENT_HASH_LENGTH = 16
2029

2130

22-
class OrderQuerySet(BaseAbstractModelQuerySet):
31+
class PaymentPreparationMixin:
32+
id: UUID
33+
prepared_cart_snapshot: dict[str, typing.Any] | None
34+
prepared_cart_hash: str | None
35+
first_paid_price: int
36+
products: typing.Any
37+
38+
@property
39+
def merchant_uid(self) -> str | None:
40+
return f"{shortuuid.encode(self.id)}.{self.prepared_cart_hash}" if self.prepared_cart_hash else None
41+
42+
@property
43+
def prepared_price(self) -> int | None:
44+
return self.prepared_cart_snapshot["price"] if self.prepared_cart_snapshot else None
45+
46+
def get_current_cart_snapshot(self, *, attempt: dict[str, str] | None = None) -> dict[str, typing.Any]:
47+
products = self.products.filter_active().prefetch_related(
48+
models.Prefetch("options", queryset=OrderProductOptionRelation.objects.filter_active()),
49+
)
50+
return {
51+
"attempt": attempt or {"id": str(uuid4()), "prepared_at": now_aware().isoformat()},
52+
"price": self.first_paid_price,
53+
"products": [
54+
{
55+
"id": str(product_rel.id),
56+
"product": str(product_rel.product_id),
57+
"price": product_rel.price,
58+
"donation_price": product_rel.donation_price,
59+
"options": [
60+
{
61+
"id": str(option_rel.id),
62+
"product_option_group": str(option_rel.product_option_group_id),
63+
"product_option": (
64+
str(option_rel.product_option_id) if option_rel.product_option_id is not None else None
65+
),
66+
"custom_response": option_rel.custom_response,
67+
}
68+
for option_rel in sorted(product_rel.options.all(), key=lambda option_rel: str(option_rel.id))
69+
],
70+
}
71+
for product_rel in sorted(products, key=lambda rel: str(rel.id))
72+
],
73+
}
74+
75+
@staticmethod
76+
def _compute_cart_hash(snapshot: dict[str, typing.Any]) -> str:
77+
digest = hmac_new(
78+
settings.SECRET_KEY.encode(),
79+
json.dumps(snapshot, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode(),
80+
sha256,
81+
).digest()
82+
return urlsafe_b64encode(digest[:12]).decode().rstrip("=")
83+
84+
def get_current_cart_hash(self, *, attempt: dict[str, str] | None = None) -> str:
85+
return self._compute_cart_hash(self.get_current_cart_snapshot(attempt=attempt))
86+
87+
def prepare_payment(self) -> None:
88+
snapshot = self.get_current_cart_snapshot()
89+
self.prepared_cart_snapshot = snapshot
90+
self.prepared_cart_hash = self._compute_cart_hash(snapshot)
91+
self.save(update_fields={"prepared_cart_snapshot", "prepared_cart_hash"})
92+
93+
def matches_payment_preparation(self, merchant_uid: str, amount: int | float) -> bool:
94+
if not self.prepared_cart_snapshot:
95+
return False
96+
try:
97+
amount_int = int(amount)
98+
except (TypeError, ValueError):
99+
return False
100+
attempt = self.prepared_cart_snapshot.get("attempt")
101+
if not isinstance(attempt, dict):
102+
return False
103+
return (
104+
self.merchant_uid == merchant_uid
105+
and amount == self.prepared_price == amount_int
106+
and self.prepared_cart_hash == self.get_current_cart_hash(attempt=attempt)
107+
)
108+
109+
110+
class BaseCartQuerySet(BaseAbstractModelQuerySet):
111+
def filter_by_merchant_uid(self, merchant_uid: object) -> models.QuerySet:
112+
with suppress(AttributeError, TypeError, ValueError):
113+
encoded_id, cart_hash = merchant_uid.split(".", 1)
114+
return self.filter_active().filter(id=shortuuid.decode(encoded_id), prepared_cart_hash=cart_hash)
115+
return self.none()
116+
117+
118+
class OrderQuerySet(BaseCartQuerySet):
23119
def filter_has_payment_histories(self) -> models.QuerySet[Order]:
24120
return self.filter_active().filter(models.Exists(PaymentHistory.objects.filter(order=models.OuterRef("id"))))
25121

@@ -67,11 +163,13 @@ def filter_in_last_six_months(self) -> models.QuerySet[Order]:
67163
return self.filter(created_at__gte=datetime.date.today() - datetime.timedelta(days=183))
68164

69165

70-
class Order(ScanCodeMixin, BaseAbstractModel):
166+
class Order(PaymentPreparationMixin, ScanCodeMixin, BaseAbstractModel):
71167
scancode_prefix = "order"
72168

73169
user = models.ForeignKey(UserModel, on_delete=models.PROTECT)
74170
name = models.TextField()
171+
prepared_cart_snapshot = models.JSONField(null=True, blank=True)
172+
prepared_cart_hash = models.CharField(max_length=PAYMENT_HASH_LENGTH, null=True, blank=True)
75173

76174
payment_histories: BaseManager[PaymentHistory]
77175
products: BaseManager[OrderProductRelation]
@@ -234,6 +332,37 @@ class OrderProductStatus(models.TextChoices):
234332
def __str__(self) -> str: # pragma: no cover
235333
return f"[{self.order}] {self.product} ({self.get_status_display()})"
236334

335+
def save( # type: ignore[override]
336+
self,
337+
*,
338+
force_insert: bool = False,
339+
force_update: bool = False,
340+
using: str | None = None,
341+
update_fields: Iterable[str] | None = None,
342+
clear_parent_preparation: bool = True,
343+
) -> None:
344+
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
345+
if clear_parent_preparation:
346+
self._clear_parent_payment_preparation()
347+
348+
def delete(self, using: str | None = None) -> None:
349+
self._clear_parent_payment_preparation()
350+
super().delete(using=using)
351+
352+
def _clear_parent_payment_preparation(self) -> None:
353+
if self.status != OrderProductRelation.OrderProductStatus.pending:
354+
return
355+
356+
# `filter().update()` — snapshot 이 존재하는 row 만 매칭하는 단일 UPDATE.
357+
# 대부분의 cart 편집은 snapshot 없는 상태라 fetch 없이 0 row UPDATE 로 끝남.
358+
has_snapshot = models.Q(prepared_cart_snapshot__isnull=False) | models.Q(prepared_cart_hash__isnull=False)
359+
cleared = {"prepared_cart_snapshot": None, "prepared_cart_hash": None}
360+
361+
if self.order_id:
362+
Order.objects.filter(id=self.order_id).filter(has_snapshot).update(**cleared)
363+
return
364+
SingleProductCart.objects.filter(order_product_relation_id=self.id).filter(has_snapshot).update(**cleared)
365+
237366
@property
238367
def not_refundable_reason(self) -> str | None:
239368
"""
@@ -280,14 +409,43 @@ def __str__(self) -> str: # pragma: no cover
280409
name = self.product_option.name if self.product_option else self.custom_response
281410
return f"{self.product_option_group.name} - {name}"
282411

412+
def save( # type: ignore[override]
413+
self,
414+
*,
415+
force_insert: bool = False,
416+
force_update: bool = False,
417+
using: str | None = None,
418+
update_fields: Iterable[str] | None = None,
419+
) -> None:
420+
super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
421+
self._clear_parent_payment_preparation()
422+
423+
def delete(self, using: str | None = None) -> None:
424+
self._clear_parent_payment_preparation()
425+
super().delete(using=using)
426+
427+
def _clear_parent_payment_preparation(self) -> None:
428+
order_product_relation = self.order_product_relation
429+
if order_product_relation.status != OrderProductRelation.OrderProductStatus.pending:
430+
return
431+
order_product_relation._clear_parent_payment_preparation()
283432

284-
class SingleProductCart(BaseAbstractModel):
433+
434+
class SingleProductCartQuerySet(BaseCartQuerySet):
435+
pass
436+
437+
438+
class SingleProductCart(PaymentPreparationMixin, BaseAbstractModel):
285439
user = models.ForeignKey(UserModel, on_delete=models.PROTECT)
286440
order_product_relation = models.OneToOneField(
287441
OrderProductRelation,
288442
on_delete=models.PROTECT,
289443
related_name="single_product_cart",
290444
)
445+
prepared_cart_snapshot = models.JSONField(null=True, blank=True)
446+
prepared_cart_hash = models.CharField(max_length=PAYMENT_HASH_LENGTH, null=True, blank=True)
447+
448+
objects: SingleProductCartQuerySet = SingleProductCartQuerySet.as_manager() # type: ignore[assignment, misc]
291449

292450
history = HistoricalRecords()
293451

@@ -298,9 +456,12 @@ def to_order(self) -> Order:
298456
name=self.order_product_relation.product.name,
299457
name_ko=self.order_product_relation.product.name_ko,
300458
name_en=self.order_product_relation.product.name_en,
459+
prepared_cart_snapshot=self.prepared_cart_snapshot,
460+
prepared_cart_hash=self.prepared_cart_hash,
301461
)
302462
self.order_product_relation.order = order
303-
self.order_product_relation.save()
463+
# cart→Order 승격은 결제 직전 단계이므로 prepared snapshot 을 유지한 채 OPR 의 parent FK 만 갱신.
464+
self.order_product_relation.save(clear_parent_preparation=False)
304465

305466
CustomerInfo.objects.filter(single_product_cart=self).update(order=order, single_product_cart=None)
306467

@@ -335,9 +496,9 @@ def is_cart(self) -> typing.Literal[True]:
335496
def payment_histories(self) -> list[PaymentHistory]:
336497
return []
337498

338-
@functools.cached_property
339-
def products(self) -> list[OrderProductRelation]:
340-
return [self.order_product_relation]
499+
@property
500+
def products(self) -> models.QuerySet[OrderProductRelation]:
501+
return OrderProductRelation.objects.filter(id=self.order_product_relation_id)
341502

342503
@functools.cached_property
343504
def name(self) -> str:

0 commit comments

Comments
 (0)