Update project

This commit is contained in:
2026-08-19 17:15:55 +03:30
parent 02d40e1238
commit f945f960c9
25 changed files with 1092 additions and 287 deletions

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -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}"

View File

@@ -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

View File

@@ -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'])