Payment event ledger (PaymentEvent) replicated to the backup server, plus CSV export script on 247
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
53
ops/pgbackup/export-payment-events.sh
Executable file
53
ops/pgbackup/export-payment-events.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# روی سرورِ بکاپ (۲۴۷). دفترِ رویدادهای خرید (payment_paymentevent) و سفارش/تراکنش را از روی همین
|
||||||
|
# بکاپ (آخرین نسخهی کامل + همهی WALِ رسیده) به CSVِ خوانا در `payment-log/` کنارِ `base/` و `wal/` مینویسد.
|
||||||
|
# هیچ اتصالی به ۲۴۹ یا Postgresِ دیگرانِ این ماشین ندارد: همان روشِ restore-check.sh، نمونهی موقت روی
|
||||||
|
# سوکتِ داخلِ پوشه (بیپورتِ شبکه) که آخرش پاک میشود. فقط زیرِ ~/zareian-pgbackup مینویسد.
|
||||||
|
# خروجی: payment-log/payment-events-<UTC>.csv، orders-…csv، transactions-…csv؛ ۱۴ فایلِ آخرِ هر نوع میماند.
|
||||||
|
# توجه: نسخهی کامل تا چند دقیقهی WAL عقبتر از ۲۴۹ میشود، ولی WALِ رسیده همه اعمال میشود.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT=/home/administrator/zareian-pgbackup
|
||||||
|
BIN=/usr/lib/postgresql/14/bin
|
||||||
|
OUT="$ROOT/payment-log"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# دیسکِ این ماشین مشترک است؛ زیرِ ۳ گیگ فضای آزاد، شروع نمیکنیم.
|
||||||
|
free_kb=$(df --output=avail -k "$ROOT" | tail -1)
|
||||||
|
[ "$free_kb" -gt 3000000 ] || { echo "disk too full ($free_kb KB free); skipping" >&2; exit 1; }
|
||||||
|
|
||||||
|
latest=$(ls -1d "$ROOT"/base/2* | tail -1)
|
||||||
|
work=$(mktemp -d "$ROOT/export-events.XXXXXX")
|
||||||
|
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
cp -a "$latest" "$work/data"
|
||||||
|
mkdir "$work/wal"
|
||||||
|
cp "$ROOT"/wal/* "$work/wal/"
|
||||||
|
for f in "$work"/wal/*.partial; do [ -e "$f" ] && mv "$f" "${f%.partial}"; done
|
||||||
|
|
||||||
|
cat > "$work/data/postgresql.conf" <<CONF
|
||||||
|
listen_addresses = ''
|
||||||
|
unix_socket_directories = '$work'
|
||||||
|
restore_command = 'cp $work/wal/%f %p'
|
||||||
|
recovery_target_action = 'promote'
|
||||||
|
CONF
|
||||||
|
echo 'local all all trust' > "$work/data/pg_hba.conf"
|
||||||
|
: > "$work/data/postgresql.auto.conf"
|
||||||
|
touch "$work/data/recovery.signal"
|
||||||
|
|
||||||
|
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
|
||||||
|
for _ in $(seq 120); do
|
||||||
|
[ "$("$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
q() { "$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -c "\\copy ($1) to '$2' csv header"; }
|
||||||
|
q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" "$OUT/payment-events-$stamp.csv"
|
||||||
|
q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" "$OUT/orders-$stamp.csv"
|
||||||
|
q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" "$OUT/transactions-$stamp.csv"
|
||||||
|
chmod 600 "$OUT"/*-"$stamp".csv
|
||||||
|
|
||||||
|
for kind in payment-events orders transactions; do
|
||||||
|
ls -1t "$OUT/$kind"-*.csv | tail -n +15 | xargs -r rm -f --
|
||||||
|
done
|
||||||
|
echo "wrote $OUT/*-$stamp.csv from base $(basename "$latest")"
|
||||||
7
ops/pgbackup/zareian-pg-export-events.service
Normal file
7
ops/pgbackup/zareian-pg-export-events.service
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Zareian MathApp: export payment events from the backup copy to CSV
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=administrator
|
||||||
|
ExecStart=/home/administrator/zareian-pgbackup/bin/export-payment-events.sh
|
||||||
9
ops/pgbackup/zareian-pg-export-events.timer
Normal file
9
ops/pgbackup/zareian-pg-export-events.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Zareian MathApp: payment events CSV every 6 hours
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 01,07,13,19:15:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -2,7 +2,7 @@ from django.contrib import admin, messages
|
|||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from . import bazaar
|
from . import bazaar
|
||||||
from .models import BazaarCredential, Order, Transaction
|
from .models import BazaarCredential, Order, PaymentEvent, Transaction
|
||||||
|
|
||||||
|
|
||||||
class TransactionInline(admin.TabularInline):
|
class TransactionInline(admin.TabularInline):
|
||||||
|
|||||||
29
payment/events.py
Normal file
29
payment/events.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import PaymentEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def record(event: str, *, order=None, user_id=None, provider: str = '', **detail) -> None:
|
||||||
|
"""
|
||||||
|
یک ردیف در PaymentEvent مینویسد.
|
||||||
|
|
||||||
|
شکستِ نوشتن *را میبلعد ولی لاگ میکند* (logger.exception): این دفتر فقط ردگیری است و خطایش
|
||||||
|
نباید پرداختِ کاربر را بشکند یا سفارشِ پرداختشده را ناتمام بگذارد. savepoint دارد تا خطای
|
||||||
|
این insert تراکنشِ بیرونی را در Postgres خراب نکند. زیرِ فشار/قطعیِ دیتابیس آزموده نشده.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
PaymentEvent.objects.create(
|
||||||
|
order_id=order.id if order is not None else None,
|
||||||
|
user_id=user_id if user_id is not None else (order.user_id if order is not None else None),
|
||||||
|
provider=provider or (order.provider if order is not None else ''),
|
||||||
|
event=event,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('payment event %s could not be recorded (order=%s)', event,
|
||||||
|
getattr(order, 'id', None))
|
||||||
28
payment/migrations/0014_paymentevent.py
Normal file
28
payment/migrations/0014_paymentevent.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-10-13 10:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payment', '0013_remove_question_packs'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PaymentEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('order_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('user_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('provider', models.CharField(blank=True, max_length=20)),
|
||||||
|
('event', models.CharField(db_index=True, max_length=40)),
|
||||||
|
('detail', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -115,3 +115,28 @@ class BazaarCredential(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'متصل' if self.refresh_token else 'متصل نیست'
|
return 'متصل' if self.refresh_token else 'متصل نیست'
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentEvent(models.Model):
|
||||||
|
"""
|
||||||
|
دفترِ رویدادهای مسیرِ خرید (ساخت سفارش، رفتن به درگاه، بازگشت از درگاه، نتیجهی verify،
|
||||||
|
لغو/بازپرداخت). فقط افزودنی است و هیچ منطقی بر آن تکیه ندارد.
|
||||||
|
|
||||||
|
چرا جدول و نه فایلِ لاگ: دیتابیس لحظهای به سرورِ بکاپِ ۲۴۷ میرود (WAL)، پس این ردیفها
|
||||||
|
بیهیچ کانال و دسترسیِ تازه همانجا هم میرسند؛ فایلِ خوانا از روی همان نسخه ساخته میشود
|
||||||
|
(`ops/pgbackup/export-payment-events.sh`). `StatusChangeLog` فقط تغییرِ وضعیت را نگه میدارد؛
|
||||||
|
«کاربر رفت به درگاه ولی برنگشت» در آن نیست و اینجا هست.
|
||||||
|
در `detail` هیچ توکنِ خرید یا کلیدی نمیرود؛ authority فقط ۶۴ نویسهی اول.
|
||||||
|
"""
|
||||||
|
order_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به Order؛ بیسفارش هم میشود
|
||||||
|
user_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||||||
|
provider = models.CharField(max_length=20, blank=True)
|
||||||
|
event = models.CharField(max_length=40, db_index=True)
|
||||||
|
detail = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'order {self.order_id}: {self.event}'
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from rest_framework.exceptions import APIException
|
|||||||
|
|
||||||
from statuslog.signals import status_changed
|
from statuslog.signals import status_changed
|
||||||
|
|
||||||
|
from . import events
|
||||||
|
|
||||||
from .models import Order, Transaction
|
from .models import Order, Transaction
|
||||||
from .providers import PROVIDERS, provider_for
|
from .providers import PROVIDERS, provider_for
|
||||||
|
|
||||||
@@ -135,6 +137,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
|||||||
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
events.record('order_created', order=order, plan_id=plan_id, amount=amount, currency=currency,
|
||||||
|
duration_days=order.duration_days, sku=bazaar_product_sku)
|
||||||
# بازار paymentUrl ندارد (اپ با Poolakey میخرد)؛ زرینپال آدرسِ PaymentRedirectView را دارد (providers.py).
|
# بازار paymentUrl ندارد (اپ با Poolakey میخرد)؛ زرینپال آدرسِ PaymentRedirectView را دارد (providers.py).
|
||||||
payment_url = provider_for(order.provider).payment_url(order)
|
payment_url = provider_for(order.provider).payment_url(order)
|
||||||
return order, payment_url
|
return order, payment_url
|
||||||
@@ -387,6 +391,7 @@ def cancel_pending_order(order: Order, *, staff: str = '') -> None:
|
|||||||
raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ در انتظار لغو میشود.')
|
raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ در انتظار لغو میشود.')
|
||||||
order.status = 'canceled'
|
order.status = 'canceled'
|
||||||
order.save(update_fields=['status'])
|
order.save(update_fields=['status'])
|
||||||
|
events.record('admin_cancel', order=order, staff=staff)
|
||||||
status_changed.send(sender='payment.Order', object_id=order.id, old_status='pending',
|
status_changed.send(sender='payment.Order', object_id=order.id, old_status='pending',
|
||||||
new_status='canceled', event=f'admin_cancel:{staff}'[:50], user_id=order.user_id)
|
new_status='canceled', event=f'admin_cancel:{staff}'[:50], user_id=order.user_id)
|
||||||
|
|
||||||
@@ -428,6 +433,7 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
|
|||||||
|
|
||||||
order.status = 'refunded'
|
order.status = 'refunded'
|
||||||
order.save(update_fields=['status'])
|
order.save(update_fields=['status'])
|
||||||
|
events.record('admin_refund', order=order, staff=staff, subscription_days=summary['subscription_days'])
|
||||||
Transaction.objects.create(order=order, gateway='admin', status='refunded',
|
Transaction.objects.create(order=order, gateway='admin', status='refunded',
|
||||||
gateway_ref_id=f'refund-{order.id}')
|
gateway_ref_id=f'refund-{order.id}')
|
||||||
status_changed.send(sender='payment.Order', object_id=order.id, old_status='paid',
|
status_changed.send(sender='payment.Order', object_id=order.id, old_status='paid',
|
||||||
|
|||||||
27
payment/tests_events.py
Normal file
27
payment/tests_events.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from payment import events
|
||||||
|
from payment.models import Order, PaymentEvent
|
||||||
|
from payment.services import cancel_pending_order, create_order
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentEventTests(TestCase):
|
||||||
|
def test_create_order_records_event_once_per_order(self):
|
||||||
|
order, _ = create_order(user_id=7, amount=1000, idempotency_key='k-1')
|
||||||
|
create_order(user_id=7, amount=1000, idempotency_key='k-1') # همان سفارش، رویدادِ دوم نه
|
||||||
|
rows = PaymentEvent.objects.filter(order_id=order.id, event='order_created')
|
||||||
|
self.assertEqual(rows.count(), 1)
|
||||||
|
self.assertEqual(rows.get().user_id, 7)
|
||||||
|
|
||||||
|
def test_cancel_records_event(self):
|
||||||
|
order, _ = create_order(user_id=7, amount=1000)
|
||||||
|
cancel_pending_order(order, staff='tester')
|
||||||
|
self.assertTrue(PaymentEvent.objects.filter(order_id=order.id, event='admin_cancel').exists())
|
||||||
|
|
||||||
|
def test_failure_to_record_does_not_raise(self):
|
||||||
|
with mock.patch.object(PaymentEvent.objects, 'create', side_effect=RuntimeError('db')):
|
||||||
|
with self.assertLogs('payment.events', level='ERROR'):
|
||||||
|
events.record('x', user_id=1)
|
||||||
|
self.assertEqual(Order.objects.count(), 0)
|
||||||
@@ -15,7 +15,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.throttling import ScopedRateThrottle
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from . import bazaar, zarinpal
|
from . import bazaar, events, zarinpal
|
||||||
from .models import Order
|
from .models import Order
|
||||||
from .permissions import HasInternalServiceKey
|
from .permissions import HasInternalServiceKey
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
@@ -103,12 +103,18 @@ class PaymentRedirectView(APIView):
|
|||||||
if order.status != 'pending' or order.gateway_authority:
|
if order.status != 'pending' or order.gateway_authority:
|
||||||
break
|
break
|
||||||
requested_amount = order.amount
|
requested_amount = order.amount
|
||||||
|
try:
|
||||||
result = zarinpal.request_payment(order)
|
result = zarinpal.request_payment(order)
|
||||||
|
except Exception as exc:
|
||||||
|
events.record('gateway_request_failed', order=order, error=type(exc).__name__)
|
||||||
|
raise
|
||||||
Order.objects.filter(pk=order.pk, gateway_authority='', amount=requested_amount).update(
|
Order.objects.filter(pk=order.pk, gateway_authority='', amount=requested_amount).update(
|
||||||
gateway_authority=result.authority,
|
gateway_authority=result.authority,
|
||||||
)
|
)
|
||||||
order.refresh_from_db(fields=['gateway_authority', 'amount', 'status'])
|
order.refresh_from_db(fields=['gateway_authority', 'amount', 'status'])
|
||||||
|
|
||||||
|
events.record('gateway_redirect', order=order, status=order.status,
|
||||||
|
authority_set=bool(order.gateway_authority))
|
||||||
if order.status != 'pending':
|
if order.status != 'pending':
|
||||||
# سفارش از قبل نتیجه گرفته (paid/failed، یا کدِ ۱۰۰٪ همین الان)؛ چیزی برای پرداختِ تازه نیست.
|
# سفارش از قبل نتیجه گرفته (paid/failed، یا کدِ ۱۰۰٪ همین الان)؛ چیزی برای پرداختِ تازه نیست.
|
||||||
# کاربر به اپ برمیگردد با همان نتیجه، نه به ریشهی دامنهی API.
|
# کاربر به اپ برمیگردد با همان نتیجه، نه به ریشهی دامنهی API.
|
||||||
@@ -159,7 +165,10 @@ class ZarinpalCallbackView(APIView):
|
|||||||
if order is None:
|
if order is None:
|
||||||
# کالبکِ بیسفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد.
|
# کالبکِ بیسفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد.
|
||||||
logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10])
|
logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10])
|
||||||
|
events.record('callback_unknown_authority', provider='gateway',
|
||||||
|
authority=authority[:64], status_param=status_param[:10])
|
||||||
raise Http404
|
raise Http404
|
||||||
|
events.record('callback_received', order=order, authority=authority[:64], status_param=status_param[:10])
|
||||||
if status_param != 'OK':
|
if status_param != 'OK':
|
||||||
new_status, gateway_ref_id = 'failed', ''
|
new_status, gateway_ref_id = 'failed', ''
|
||||||
else:
|
else:
|
||||||
@@ -168,6 +177,7 @@ class ZarinpalCallbackView(APIView):
|
|||||||
)
|
)
|
||||||
new_status = 'paid' if result.is_valid else 'failed'
|
new_status = 'paid' if result.is_valid else 'failed'
|
||||||
gateway_ref_id = result.ref_id
|
gateway_ref_id = result.ref_id
|
||||||
|
events.record('verify_result', order=order, valid=bool(result.is_valid), ref_id=gateway_ref_id)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
||||||
@@ -306,6 +316,7 @@ class BazaarVerifyPurchaseView(APIView):
|
|||||||
# apply_order_payment_result سفارش را از نو میخواند و همان را عوض میکند؛ این نمونه هنوز
|
# apply_order_payment_result سفارش را از نو میخواند و همان را عوض میکند؛ این نمونه هنوز
|
||||||
# وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالیکه سفارش «paid» شده بود.
|
# وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالیکه سفارش «paid» شده بود.
|
||||||
order.refresh_from_db(fields=['status'])
|
order.refresh_from_db(fields=['status'])
|
||||||
|
events.record('bazaar_verify', order=order, valid=bool(result.is_valid), status=order.status)
|
||||||
|
|
||||||
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
|
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
|
||||||
return Response(BazaarVerifyResponseSerializer(data).data)
|
return Response(BazaarVerifyResponseSerializer(data).data)
|
||||||
@@ -366,6 +377,7 @@ class BazaarStartPurchaseView(APIView):
|
|||||||
# علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است.
|
# علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است.
|
||||||
order.bazaar_price_rial = _to_rial(order.amount, order.currency)
|
order.bazaar_price_rial = _to_rial(order.amount, order.currency)
|
||||||
order.save(update_fields=['bazaar_price_rial'])
|
order.save(update_fields=['bazaar_price_rial'])
|
||||||
|
events.record('bazaar_start', order=order, amount=order.amount, discounted=token is not None)
|
||||||
data = {
|
data = {
|
||||||
'orderId': str(order.id),
|
'orderId': str(order.id),
|
||||||
'sku': order.bazaar_product_sku,
|
'sku': order.bazaar_product_sku,
|
||||||
|
|||||||
Reference in New Issue
Block a user