Update project
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-19 10:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0002_order_bazaar_product_sku_order_bazaar_purchase_token_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='transaction',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('gateway_ref_id__gt', '')), fields=('order', 'gateway_ref_id', 'status'), name='uniq_transaction_order_ref_status'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-19 11:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0003_transaction_uniq_transaction_order_ref_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='transaction',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('gateway_ref_id__gt', '')), fields=('gateway', 'gateway_ref_id'), name='uniq_transaction_gateway_ref_global'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-19 11:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payment', '0004_transaction_uniq_transaction_gateway_ref_global'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name='transaction',
|
||||
name='uniq_transaction_order_ref_status',
|
||||
),
|
||||
]
|
||||
@@ -47,6 +47,25 @@ class Transaction(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
constraints = [
|
||||
# 🔒 یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای
|
||||
# غیرخالی: این قید سطح DB تضمین میکنه یک gateway_ref_id مشخص
|
||||
# (مثلاً Authority زرینپال یا purchaseToken بازار) در کل جدول
|
||||
# Transaction فقط میتونه متعلق به یک Order باشه — هم جلوی
|
||||
# duplicate/idempotency برای همون Order رو میگیره (چون در کل
|
||||
# جدول حداکثر یک ردیف با این (gateway, ref) میتونه وجود داشته
|
||||
# باشه) و هم جلوی reuse/replay همون ref برای یک Order دیگه رو
|
||||
# (نگاه کنید به apply_order_payment_result). این قید، قیدِ
|
||||
# قدیمیترِ (order, gateway_ref_id, status) رو هم در برمیگیره
|
||||
# (چون سختگیرانهتره)، برای همین دیگه لازم نیست جدا نگه داشته
|
||||
# بشه. gateway_ref_id__gt='' یعنی رفرنسهای خالی (که provider
|
||||
# واقعی هنوز رفرنسی برنگردونده) مشمول این قید نمیشن.
|
||||
models.UniqueConstraint(
|
||||
fields=['gateway', 'gateway_ref_id'],
|
||||
condition=models.Q(gateway_ref_id__gt=''),
|
||||
name='uniq_transaction_gateway_ref_global',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"txn-{self.pk}-order{self.order_id}"
|
||||
|
||||
@@ -123,44 +123,114 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
|
||||
idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل
|
||||
موجود باشه، دوباره اثر جانبی اجرا نمیشه. خروجی: True اگه این فراخوانی
|
||||
واقعاً باعث paid شدن تازه شده باشه.
|
||||
|
||||
⚛️🔒 اصلاح race condition: قبلاً already_paid/is_duplicate روی همون
|
||||
آبجکتِ order ورودی محاسبه میشدن، و تابع کاملاً به این متکی بود که
|
||||
caller از قبل select_for_update() گرفته باشه (که الان درسته، ولی
|
||||
تضمینشده نبود — هر caller جدیدی که این فرض رو رعایت نکنه، race رو
|
||||
برمیگردوند). حالا خودِ این تابع، صرفنظر از caller، ردیفِ Order رو
|
||||
دوباره با select_for_update قفل و رفرش میکنه (تودرتو بودنِ
|
||||
select_for_update با atomic بیرونی مشکلی نداره) و سپس already_paid/
|
||||
is_duplicate را *بعد* از قفل محاسبه میکنه. بهعلاوه، ساختِ Transaction
|
||||
حالا در برابر IntegrityQrror ناشیِ از UniqueConstraint سطح DB (دفاع در
|
||||
عمق، مخصوصاً روی بکاندی مثل SQLite که select_for_update لاک واقعی
|
||||
row-level نمیده) هم محافظت شده.
|
||||
"""
|
||||
already_paid = order.status == 'paid'
|
||||
old_status = order.status
|
||||
with db_transaction.atomic():
|
||||
order = Order.objects.select_for_update().get(pk=order.pk)
|
||||
|
||||
is_duplicate = Transaction.objects.filter(
|
||||
order=order, status=new_status, gateway_ref_id=gateway_ref_id,
|
||||
).exists()
|
||||
already_paid = order.status == 'paid'
|
||||
old_status = order.status
|
||||
|
||||
if not is_duplicate:
|
||||
order.status = new_status
|
||||
order.save(update_fields=['status'])
|
||||
is_duplicate = Transaction.objects.filter(
|
||||
order=order, status=new_status, gateway_ref_id=gateway_ref_id,
|
||||
).exists()
|
||||
|
||||
Transaction.objects.create(
|
||||
order=order,
|
||||
gateway=gateway,
|
||||
gateway_ref_id=gateway_ref_id,
|
||||
status=new_status,
|
||||
paid_at=timezone.now() if new_status == 'paid' else None,
|
||||
)
|
||||
# 🔒 اصلاح race-regression: اگه سفارش زیرِ لاک همین الان paid باشه،
|
||||
# یک callback دیرهنگام/خارج از ترتیب با new_status='failed' (یا هر
|
||||
# چیزی غیر از 'paid') نباید status رو به عقب برگردونه. چنین
|
||||
# callbackی رو نسبت به وضعیتِ Order و اثرات جانبیِ پرداخت کاملاً
|
||||
# idempotent/no-op در نظر میگیریم (نه تغییر status، نه Transaction
|
||||
# جدید، نه سیگنال/نوتیفای). این مسیر برای انتقالِ مشروعِ
|
||||
# pending/failed → paid دست نخورده باقی میمونه.
|
||||
if already_paid and new_status != 'paid':
|
||||
is_duplicate = True
|
||||
|
||||
status_changed.send(
|
||||
sender='payment.Order', object_id=order.id,
|
||||
old_status=old_status, new_status=new_status,
|
||||
event=event, user_id=order.user_id,
|
||||
)
|
||||
# 🔒 اصلاح duplicate/idempotency روی reuse رفرنسِ پرداخت: اگه همین
|
||||
# (gateway, gateway_ref_id) غیرخالی از قبل متعلق به یک Order دیگه
|
||||
# باشه (replay/reuse یک authority یا purchaseToken برای سفارشِ
|
||||
# دیگه)، این callback رو هم مثل یک duplicate/idempotent no-op در
|
||||
# نظر میگیریم — نه status این سفارش عوض میشه، نه Transaction جدید
|
||||
# و نه اثرات جانبی (activate_subscription/notify). این چک قبل از
|
||||
# هر تغییری روی order انجام میشه تا حتی در بدترین حالت هم status
|
||||
# این سفارش دستنخورده بمونه.
|
||||
if gateway_ref_id and Transaction.objects.filter(
|
||||
gateway=gateway, gateway_ref_id=gateway_ref_id,
|
||||
).exclude(order=order).exists():
|
||||
is_duplicate = True
|
||||
|
||||
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
|
||||
if newly_paid:
|
||||
if order.subscription_id:
|
||||
from subscription.services import activate_subscription
|
||||
activate_subscription(order.subscription_id)
|
||||
if not is_duplicate:
|
||||
order.status = new_status
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
from notifications.services import notify
|
||||
notify(
|
||||
user_id=order.user_id,
|
||||
type='order_paid',
|
||||
title='پرداخت با موفقیت انجام شد',
|
||||
related_id=order.id,
|
||||
)
|
||||
try:
|
||||
# 🔒 اصلاح: قبلاً این create بدونِ هیچ savepointِ تودرتویی
|
||||
# داخلِ همون transaction.atomic بیرونی (خط ۱۳۹) اجرا میشد.
|
||||
# روی بکاندهایی مثل PostgreSQL، وقتی یک IntegrityError داخلِ
|
||||
# یک atomic block رخ میده، کلِ اون تراکنش (نه فقط همین
|
||||
# کوئری) «broken»/aborted علامت میخوره؛ گرفتنِ exception با
|
||||
# except بدونِ rollback به یک savepoint، این وضعیتِ broken رو
|
||||
# پاک نمیکنه — یعنی هر کوئریِ بعدی در همین atomic (مثلِ
|
||||
# order.save بالاتر که قبلاً commit نشده، یا status_changed/
|
||||
# notify/activate_subscription پایینتر) با خطای دیگهای
|
||||
# (InFailedSqlTransaction) fail میکرد، نه با رفتارِ
|
||||
# idempotentِ موردنظر. حالا خودِ این create داخلِ یک
|
||||
# transaction.atomic تودرتو (savepoint) اجرا میشه: اگه
|
||||
# IntegrityError بده، فقط همین savepoint rollback میشه و
|
||||
# atomic بیرونی سالم میمونه، پس ادامهی تابع (خطِ
|
||||
# newly_paid و برگردوندنِ نتیجه) بهدرستی اجرا میشه.
|
||||
with db_transaction.atomic():
|
||||
Transaction.objects.create(
|
||||
order=order,
|
||||
gateway=gateway,
|
||||
gateway_ref_id=gateway_ref_id,
|
||||
status=new_status,
|
||||
paid_at=timezone.now() if new_status == 'paid' else None,
|
||||
)
|
||||
except IntegrityError:
|
||||
# دفاع در عمق: با اینکه بالاتر لاک گرفتیم و چک کردیم، اگه
|
||||
# به هر دلیلی (مثلاً DB بدون row-level lock واقعی، یا یک
|
||||
# race همزمانِ reuse رفرنس روی یک Order دیگه) یک Transaction
|
||||
# با همون (gateway, gateway_ref_id) همزمان ساخته شده باشه،
|
||||
# UniqueConstraintِ سراسریِ uniq_transaction_gateway_ref_global
|
||||
# جلوش رو گرفته؛ این حالت رو duplicate در نظر میگیریم و
|
||||
# اثرات جانبی رو دوباره اجرا نمیکنیم. همچنین چون Transaction
|
||||
# ساخته نشد، statusای که بالاتر روی order ست شده بود رو به
|
||||
# مقدار قبلی برمیگردونیم تا این سفارش هیچوقت با یک رفرنسِ
|
||||
# رد شده paid/تغییریافته باقی نمونه.
|
||||
is_duplicate = True
|
||||
if order.status != old_status:
|
||||
order.status = old_status
|
||||
order.save(update_fields=['status'])
|
||||
else:
|
||||
status_changed.send(
|
||||
sender='payment.Order', object_id=order.id,
|
||||
old_status=old_status, new_status=new_status,
|
||||
event=event, user_id=order.user_id,
|
||||
)
|
||||
|
||||
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
|
||||
if newly_paid:
|
||||
if order.subscription_id:
|
||||
from subscription.services import activate_subscription
|
||||
activate_subscription(order.subscription_id)
|
||||
|
||||
from notifications.services import notify
|
||||
notify(
|
||||
user_id=order.user_id,
|
||||
type='order_paid',
|
||||
title='پرداخت با موفقیت انجام شد',
|
||||
related_id=order.id,
|
||||
)
|
||||
|
||||
return newly_paid
|
||||
|
||||
@@ -149,17 +149,28 @@ class BazaarVerifyPurchaseView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
v = serializer.validated_data
|
||||
|
||||
# ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست
|
||||
# پشتسرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic
|
||||
# و زیر select_for_update روی Order انجام میشد؛ یعنی ردیف Order (و
|
||||
# هر تراکنش دیگهای که بخواد همون Order یا Subscription مرتبط رو لمس
|
||||
# کنه) تا برگشتِ پاسخ بازار قفل میموند. حالا verify_purchase قبل از
|
||||
# باز شدن atomic/lock صدا زده میشه؛ لاک فقط برای اعمال نتیجه (چند
|
||||
# میلیثانیه) گرفته میشه.
|
||||
order_lookup = get_object_or_404(
|
||||
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||||
)
|
||||
|
||||
result = bazaar.verify_purchase(
|
||||
product_sku=order_lookup.bazaar_product_sku or v['productSku'],
|
||||
purchase_token=v['purchaseToken'],
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
order = get_object_or_404(
|
||||
Order.objects.select_for_update(),
|
||||
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||||
)
|
||||
|
||||
result = bazaar.verify_purchase(
|
||||
product_sku=order.bazaar_product_sku or v['productSku'],
|
||||
purchase_token=v['purchaseToken'],
|
||||
)
|
||||
|
||||
new_status = 'paid' if result.is_valid else 'failed'
|
||||
order.bazaar_purchase_token = v['purchaseToken']
|
||||
order.save(update_fields=['bazaar_purchase_token'])
|
||||
|
||||
Reference in New Issue
Block a user