- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
11 KiB
Python
202 lines
11 KiB
Python
import logging
|
||
|
||
from django.db import models, transaction
|
||
from django.utils import timezone
|
||
|
||
from statuslog.signals import status_changed
|
||
|
||
from .models import HeartsPass, QuestionCredit, Subscription
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def has_unlimited_hearts(user_id: int) -> bool:
|
||
"""
|
||
قلبِ نامحدود: اشتراکِ فعال (expires_at=None یعنی اشتراکِ نامحدودِ هدیه)، یا کارتِ قلبِ
|
||
هدیهی بستههای سوال (HeartsPass) که هنوز تمام نشده.
|
||
"""
|
||
now = timezone.now()
|
||
if HeartsPass.objects.filter(user_id=user_id, expires_at__gt=now).exists():
|
||
return True
|
||
return Subscription.objects.filter(
|
||
user_id=user_id, status='active',
|
||
).filter(
|
||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
|
||
).exists()
|
||
|
||
|
||
def has_active_subscription(user_id: int) -> bool:
|
||
"""فقط اشتراکِ زمانی (نه کارتِ قلبِ بستهها)؛ برای متنِ پیامِ سقفِ روزانه."""
|
||
now = timezone.now()
|
||
return Subscription.objects.filter(user_id=user_id, status='active').filter(
|
||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)).exists()
|
||
|
||
|
||
def hearts_pass_until(user_id: int):
|
||
"""تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده."""
|
||
hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first()
|
||
return hp.expires_at if hp else None
|
||
|
||
|
||
# ── اعتبارِ سوال (بستهها) ────────────────────────────────────────────────
|
||
|
||
def credit_balance(user_id: int) -> int:
|
||
return QuestionCredit.objects.filter(user_id=user_id).aggregate(t=models.Sum('delta'))['t'] or 0
|
||
|
||
|
||
def spend_credit(user_id: int, message_id: int) -> bool:
|
||
"""
|
||
یک اعتبار برای این سوال خرج میکند؛ False اگر موجودی صفر است.
|
||
|
||
⚠️ باید داخلِ تراکنشی صدا زده شود که ردیفِ کاربر را قفل کرده (ai_tutor.views، فازِ ۱)؛ وگرنه
|
||
دو درخواستِ همزمان هر دو موجودیِ ۱ را میبینند و هر دو خرج میکنند. روی SQLite همان
|
||
`transaction_mode: IMMEDIATE` قفلِ نوشتن را از اولِ تراکنش میگیرد.
|
||
"""
|
||
if credit_balance(user_id) <= 0:
|
||
return False
|
||
QuestionCredit.objects.create(user_id=user_id, delta=-1, reason='question', message_id=message_id)
|
||
return True
|
||
|
||
|
||
def refund_credit(message_id: int) -> None:
|
||
"""سوالی که به API نرسید (کش) یا جوابش ساخته نشد، اعتبارش را پس میگیرد."""
|
||
QuestionCredit.objects.filter(message_id=message_id, reason='question').delete()
|
||
|
||
|
||
def grant_pack(order) -> bool:
|
||
"""
|
||
بعد از تأییدِ پرداختِ سفارشِ یک بسته: اعتبارِ سوال و روزهای قلبِ نامحدود. idempotent: قیدِ
|
||
یکتای (order_id، purchase) جلوی اعتبارِ دوباره برای تأییدِ تکراری را میگیرد.
|
||
روزهای قلب از پایانِ کارتِ فعلی ادامه پیدا میکنند، نه از امروز.
|
||
"""
|
||
from django.db import IntegrityError
|
||
|
||
from .models import Plan
|
||
|
||
plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first()
|
||
if plan is None:
|
||
return False
|
||
# عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بیاین ستونها (پیش از آنها، یا ساختهشده بیرون
|
||
# از create_order) عددهای فعلیِ پلن را میگیرد، همان رفتارِ پیشین.
|
||
credits = order.question_credits if order.question_credits is not None else plan.question_credits
|
||
hearts_days = order.hearts_days if order.hearts_days is not None else plan.hearts_days
|
||
try:
|
||
with transaction.atomic():
|
||
QuestionCredit.objects.create(user_id=order.user_id, delta=credits,
|
||
reason='purchase', order_id=order.id, note=plan.title[:100])
|
||
if hearts_days:
|
||
now = timezone.now()
|
||
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
|
||
start = hp.expires_at if hp and hp.expires_at > now else now
|
||
until = start + timezone.timedelta(days=hearts_days)
|
||
HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until})
|
||
except IntegrityError:
|
||
return False
|
||
return True
|
||
|
||
|
||
# اشتراکِ بیپلن (هدیه/آفر با grant_free_subscription) همان سقفِ پیشفرضِ پلن را میگیرد.
|
||
DEFAULT_SUBSCRIBER_AI_QUOTA = 15
|
||
|
||
|
||
def ai_daily_quota(user_id: int) -> int:
|
||
"""
|
||
سقفِ سوالِ روزانه از دستیار برای این کاربر: بیشترینِ سقفِ پلنهای اشتراکِ فعالش،
|
||
یا AI_DAILY_QUOTA_FREE اگر اشتراکِ فعال ندارد. تا ۲۰۲۶-۱۰-۰۱ همه ۱۵ سوال داشتند،
|
||
با اشتراک یا بیاشتراک — یعنی دستیار، گرانترین بخشِ اپ، رایگان بود.
|
||
"""
|
||
from django.conf import settings
|
||
|
||
now = timezone.now()
|
||
active = Subscription.objects.filter(user_id=user_id, status='active').filter(
|
||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now))
|
||
quotas = [s.plan.ai_daily_quota if s.plan else DEFAULT_SUBSCRIBER_AI_QUOTA
|
||
for s in active.select_related('plan') if not (s.plan and s.plan.kind == 'pack')]
|
||
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE
|
||
|
||
|
||
def activate_subscription(subscription_id: int, plan_id: int | None = None, duration_days: int | None = None):
|
||
"""
|
||
بعد از تاییدِ پرداخت صدا زده میشود (کالبکِ زرینپال، تاییدِ بازار).
|
||
|
||
- پلن از *سفارشِ پرداختشده* میآید (`plan_id`)، نه از Subscription؛ پلنِ
|
||
روی Subscription با شروعِ خریدِ دیگری عوض میشد (باگِ «ماهانه بده، سالانه
|
||
بگیر»). سفارشهای قدیمیِ بیplan_id همان رفتارِ قبلی را دارند.
|
||
- تمدید از تاریخِ انقضای فعلی حساب میشود، نه از امروز؛ پیشتر تمدیدِ زودتر
|
||
از موعد روزهای باقیمانده را پاک میکرد.
|
||
"""
|
||
from .models import Plan
|
||
|
||
with transaction.atomic():
|
||
try:
|
||
subscription = Subscription.objects.select_for_update().get(pk=subscription_id)
|
||
except Subscription.DoesNotExist:
|
||
return None
|
||
old_status = subscription.status
|
||
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
|
||
if plan_id and plan is None:
|
||
# پلنِ سفارشِ پرداختشده پاک شده؛ جایگزین (پلنِ Subscription یا ۳۰ روز)
|
||
# ممکن است کمتر از آنچه خریده شده باشد. کاربر بیاشتراک نمیماند، ولی
|
||
# باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید.
|
||
logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id)
|
||
plan = plan or subscription.plan
|
||
# duration_days از سفارش (عددِ لحظهی خرید)؛ None برای سفارشِ پیش از آن ستون.
|
||
if duration_days is None:
|
||
duration_days = plan.duration_days if plan else 30
|
||
now = timezone.now()
|
||
still_running = (
|
||
old_status == 'active' and subscription.expires_at is not None
|
||
and subscription.expires_at > now
|
||
)
|
||
start = subscription.expires_at if still_running else now
|
||
subscription.status = 'active'
|
||
subscription.plan = plan
|
||
subscription.expires_at = start + timezone.timedelta(days=duration_days)
|
||
subscription.save(update_fields=['status', 'plan', 'expires_at'])
|
||
status_changed.send(
|
||
sender='subscription.Subscription', object_id=subscription.id,
|
||
old_status=old_status, new_status='active',
|
||
event='payment_confirmed', user_id=subscription.user_id,
|
||
)
|
||
return subscription
|
||
|
||
|
||
def grant_free_subscription(user_id: int, *, duration_days: int | None = None, event: str = 'offer_applied'):
|
||
"""
|
||
فعالسازی اشتراک بدون هیچ Order/پرداختی — برای Offer (آفر رایگان محدود/کامل).
|
||
duration_days=None یعنی نامحدود (free_full)؛ عدد یعنی trial با همان تعداد روز.
|
||
|
||
مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با
|
||
select_for_update قفل میشود تا فراخوانیهای همزمان lost update نسازند.
|
||
|
||
اشتراکِ فعالی که دیرتر تمام میشود (یا نامحدود است) کوتاه نمیشود؛ آفر فقط
|
||
وقتی اثر دارد که بیشتر از چیزی که کاربر دارد بدهد. پیش از ۲۰۲۶-۰۹-۲۵ کاربری
|
||
که سالانه خریده بود و بعد کدِ آفرِ ۷ روزه میزد (یا مهمانی که خریده بود و بعد
|
||
ثبتنام میکرد و آفرِ خودکارِ ثبتنام میگرفت) با ۷ روز میماند.
|
||
"""
|
||
with transaction.atomic():
|
||
Subscription.objects.get_or_create(user_id=user_id)
|
||
subscription = Subscription.objects.select_for_update().get(user_id=user_id)
|
||
old_status = subscription.status
|
||
|
||
offered_until = (
|
||
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
|
||
)
|
||
still_active = old_status == 'active' and (
|
||
subscription.expires_at is None or subscription.expires_at > timezone.now()
|
||
)
|
||
keeps_longer = still_active and offered_until is not None and (
|
||
subscription.expires_at is None or subscription.expires_at >= offered_until
|
||
)
|
||
subscription.status = 'active'
|
||
if not keeps_longer:
|
||
subscription.expires_at = offered_until
|
||
subscription.save(update_fields=['status', 'expires_at'])
|
||
|
||
status_changed.send(
|
||
sender='subscription.Subscription', object_id=subscription.id,
|
||
old_status=old_status, new_status='active',
|
||
event=event, user_id=user_id,
|
||
)
|
||
return subscription
|