- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs) - content: grades 4 and 5 in the seed files: 7 units / 28 nodes each, 204 + 194 questions (every node >= 4), all re-solved blind once - subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored) - payment: idempotency key cannot switch provider; discount codes refused on Bazaar orders (Bazaar charges the fixed SKU price) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
9.0 KiB
Python
131 lines
9.0 KiB
Python
from django.db import models
|
||
|
||
|
||
class Plan(models.Model):
|
||
"""
|
||
⚠️ اصلاح نسبت به سند اولیه: نمونهی پاسخ GET /api/subscription/plans در
|
||
سند Endpointهای API یک فیلد boolean به اسم `popular` داره («ماهانه» →
|
||
false، «سالانه» → true) که در دیاگرام مدلها (سند معماری داده) برای
|
||
Plan اصلاً ذکر نشده. چون بدون این فیلد نمیشه دقیقاً همون response
|
||
schema رو پیادهسازی کرد، اینجا اضافه شده — دقیقاً مثل فیکس is_correct
|
||
روی content.Choice در فاز ۱.
|
||
"""
|
||
title = models.CharField(max_length=50)
|
||
price = models.PositiveIntegerField()
|
||
currency = models.CharField(max_length=10, default='IRT')
|
||
discount_percent = models.PositiveSmallIntegerField(default=0)
|
||
popular = models.BooleanField(default=False)
|
||
# منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو
|
||
# از رویِ متنِ title (مثلاً کلمهی «سالانه») حدس بزنه.
|
||
duration_days = models.PositiveIntegerField(default=30)
|
||
|
||
# کارتِ اشتراک زیرِ هر پلن فهرستِ امکانات نشان میدهد («درسهای نامحدود»،
|
||
# «دستیار هوشمند»…). تا امروز این فهرست در کدِ فرانت هاردکد بود، یعنی
|
||
# عوض کردنِ یک بند به دیپلویِ تازهی اپ نیاز داشت.
|
||
features = models.JSONField(default=list, blank=True)
|
||
# برچسبِ بالای کارت: «محبوبترین»، «بهترین ارزش». `popular` فقط بولین است
|
||
# و یک متن نمیدهد؛ این جایگزینش نیست، کنارش است.
|
||
badge = models.CharField(max_length=30, blank=True)
|
||
# چند سوال در روز از دستیار (۱۰ برای پلنهای فعلی، subscription.0008). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینهی API
|
||
# دارد (docs/ai-cost-and-guardrails.md)، پس این عدد مستقیم سودِ پلن را تعیین میکند.
|
||
# کاربرِ بدونِ اشتراک سقفِ AI_DAILY_QUOTA_FREE (settings) را دارد.
|
||
ai_daily_quota = models.PositiveSmallIntegerField(
|
||
default=15, help_text='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.')
|
||
|
||
# از ۲۰۲۶-۱۰-۰۲ پلن دو نوع است. «بستهی سوال» (pack) تعدادی سوال از دستیار میفروشد که
|
||
# منقضی نمیشود، بهعلاوهی چند روز قلبِ نامحدود؛ به Subscription دست نمیزند و سقفِ روزانه
|
||
# نمیدهد. «اشتراک» (subscription) همان مدلِ قبلی است و برای مشترکهای فعلی میماند.
|
||
KIND_CHOICES = [('pack', 'بستهی سوال'), ('subscription', 'اشتراکِ زمانی')]
|
||
kind = models.CharField(max_length=15, choices=KIND_CHOICES, default='subscription')
|
||
question_credits = models.PositiveIntegerField(
|
||
default=0, help_text='فقط بسته: چند سوال از دستیار به اعتبارِ خریدار اضافه میشود.')
|
||
hearts_days = models.PositiveSmallIntegerField(
|
||
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
|
||
# پلنی که دیگر فروخته نمیشود ولی مشترکهای فعلیاش هنوز به آن وصلاند.
|
||
on_sale = models.BooleanField(default=True, help_text='در صفحهی خرید نشان داده شود.')
|
||
# شناسهی همین بسته در پیشخانِ کافهبازار (محصولِ درونبرنامهای، مصرفشدنی). خریدِ بازار
|
||
# SKU را فقط از اینجا میگیرد، هرگز از کلاینت: وگرنه توکنِ ارزانترین محصول بستهی گران را
|
||
# فعال میکرد. خالی یعنی این بسته در بازار فروخته نمیشود (درخواستش ۴۰۰ میگیرد).
|
||
bazaar_sku = models.CharField(
|
||
max_length=100, blank=True, default='',
|
||
help_text='SKUِ محصولِ درونبرنامهای در پیشخانِ کافهبازار؛ خالی = در بازار فروخته نمیشود.')
|
||
|
||
class Meta:
|
||
constraints = [
|
||
models.UniqueConstraint(fields=['bazaar_sku'], condition=~models.Q(bazaar_sku=''),
|
||
name='plan_bazaar_sku_unique_when_set'),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
class Subscription(models.Model):
|
||
"""وضعیتِ pending برای فاصلهی بینِ ساختنِ Subscription (در purchase) و
|
||
تاییدِ پرداخت (webhook)؛ فعالسازیِ واقعی توسطِ
|
||
subscription.services.activate_subscription انجام میشه."""
|
||
STATUS_CHOICES = [
|
||
('pending', 'در انتظار پرداخت'),
|
||
('active', 'فعال'),
|
||
('expired', 'منقضی'),
|
||
('canceled', 'لغوشده'),
|
||
]
|
||
|
||
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
|
||
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, related_name='subscriptions')
|
||
status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending')
|
||
started_at = models.DateTimeField(auto_now_add=True)
|
||
expires_at = models.DateTimeField(null=True, blank=True)
|
||
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
|
||
# چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ همزمانِ
|
||
# همون کاربر میتونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسهی
|
||
# فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ
|
||
# version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو
|
||
# از نوشتنِ همزمانِ دیگری تمیز میده.
|
||
version = models.PositiveIntegerField(default=0)
|
||
|
||
def __str__(self):
|
||
return f"sub-user{self.user_id}-{self.status}"
|
||
|
||
|
||
class QuestionCredit(models.Model):
|
||
"""
|
||
دفترِ اعتبارِ سوال: هر خریدِ بسته یک ردیفِ مثبت، هر سوالی که با اعتبار پرسیده شد یک ردیفِ ۱−.
|
||
موجودی = جمعِ delta. چرا دفتر و نه یک عددِ «موجودی»: با عدد، دو درخواستِ همزمان میتوانستند
|
||
یک اعتبار را دو بار خرج کنند و هیچ راهی نبود بفهمیم هر سوال از کدام خرید آمده. کم کردن داخلِ
|
||
همان تراکنشی است که ردیفِ کاربر را قفل میکند (ai_tutor.views، SQLite با IMMEDIATE).
|
||
|
||
ردیفِ ۱− فقط وقتی پاک میشود که خودِ سوال پس گرفته شود: جوابِ ناموفق یا جوابی که از کش آمد و
|
||
به API نرسید. اعتبار منقضی نمیشود (تصمیمِ صاحبِ پروژه).
|
||
"""
|
||
# «refund»: ردیفِ منفیِ بازپرداختِ سفارش (payment.services.refund_order)؛ فقط اعتبارِ خرجنشده را پس میگیرد.
|
||
REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیهی ادمین'), ('refund', 'بازپرداخت')]
|
||
|
||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||
delta = models.IntegerField()
|
||
reason = models.CharField(max_length=10, choices=REASON_CHOICES)
|
||
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
|
||
message_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به ai_tutor.Message
|
||
note = models.CharField(max_length=100, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
constraints = [
|
||
# هر سفارش فقط یک بار اعتبار میدهد، حتی اگر تأییدِ پرداخت دو بار برسد.
|
||
models.UniqueConstraint(fields=['order_id'], condition=models.Q(reason='purchase'),
|
||
name='uniq_credit_per_paid_order'),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f'credit-user{self.user_id}:{self.delta:+d} ({self.reason})'
|
||
|
||
|
||
class HeartsPass(models.Model):
|
||
"""قلبِ نامحدودِ هدیهی بستهها تا expires_at؛ جدا از Subscription تا خریدِ بسته اشتراکِ کسی را عوض نکند."""
|
||
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
|
||
expires_at = models.DateTimeField()
|
||
|
||
def __str__(self):
|
||
return f'hearts-pass-user{self.user_id}-{self.expires_at:%Y-%m-%d}'
|