This commit is contained in:
2026-08-31 15:11:52 +03:30
parent 236002a87c
commit 463b812e3e
63 changed files with 3254 additions and 1476 deletions
+129 -18
View File
@@ -1,33 +1,142 @@
# apps/billing/providers/bazaar.py
"""
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات عمومی devapi کافه‌بازار
(https://developers.cafebazaar.ir/fa/docs/iab). این تنها API ای است که
اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این
تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
فرض مستنداتِ مسیر/پاسخ (اگر ساختار حساب توسعه‌دهنده‌ی خودت فرق دارد، فقط
همین یک تابع را با مستندات واقعی هماهنگ کن — بقیه‌ی پروژه فقط
`verify_purchase(product_id, purchase_token)` را صدا می‌زند):
تایید خرید کافه‌بازار — سمت سرور، طبق مستندات رسمی کافه‌بازار
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
تا این تابع True برنگرداند هیچ جمی اضافه نمی‌شود.
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
/inapp/{product_id}/purchases/{purchase_token}/?access_token=...
/inapp/{product_id}/purchases/{purchase_token}/
پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است.
access_token از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید
بدون ری‌دیپلوی قابل‌تعویض باشد (مثلا وقتی منقضی شد).
دو روش احراز هویت (هر دو طبق مستندات رسمی):
«روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر:
CAFEBAZAAR-PISHKHAN-API-SECRET: <token>
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
?access_token=<token>
با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا
همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده.
تشخیص این‌که کدام روش فعال است صرفا از روی BazaarSettings.access_token_
expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با
رفرش خودکار پیش از انقضا).
همه‌چیز از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید بدون
ری‌دیپلوی قابل‌تعویض باشد.
"""
import logging
import requests
from django.utils import timezone
from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__)
REQUEST_TIMEOUT_SECONDS = 15
BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth"
PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET"
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید می‌کنیم تا وسط یک درخواست
# واقعی منقضی نشود (access_token های کافه‌بازار معمولا ۳۶۰۰ ثانیه عمر دارند).
REFRESH_SAFETY_MARGIN_SECONDS = 120
class BazaarAuthError(Exception):
"""احراز هویت با کافه‌بازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق)."""
def build_authorize_url(redirect_uri: str) -> str:
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یک‌بار در مرورگر باز و
تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در
پیشخان ثبت شده."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id:
raise BazaarAuthError("Client ID در تنظیمات کافه‌بازار خالی است.")
return (
f"{OAUTH_BASE_URL}/authorize/"
f"?response_type=code&access_type=offline"
f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}"
)
def exchange_code_for_tokens(code: str, redirect_uri: str) -> None:
"""گام آخر فلوی OAuth — code گرفته‌شده از ریدایرکت (یا دستی کپی‌شده) را
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره می‌کند."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id or not settings_obj.client_secret:
raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافه‌بازار خالی است.")
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
"grant_type": "authorization_code",
"code": code,
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"redirect_uri": redirect_uri,
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
if not response.ok:
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token:
raise BazaarAuthError(
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
)
response = requests.post(
f"{OAUTH_BASE_URL}/token/",
data={
"grant_type": "refresh_token",
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"refresh_token": settings_obj.refresh_token,
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
if not response.ok:
raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}")
_save_token_response(settings_obj, response.json())
def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None:
settings_obj.access_token = body["access_token"]
# رفرش‌گرنت گاهی refresh_token جدید برنمی‌گرداند — قبلی را نگه می‌داریم.
if body.get("refresh_token"):
settings_obj.refresh_token = body["refresh_token"]
expires_in = body.get("expires_in", 3600)
settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in)
settings_obj.save(
update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"]
)
def _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict:
"""توکنِ سالم برای یک درخواست devapi را برمی‌گرداند — هدر یا پارامتر URL،
بسته به این‌که توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
انقضاست، همین‌جا اول تمدیدش می‌کند)."""
is_oauth_managed = settings_obj.access_token_expires_at is not None
if is_oauth_managed:
margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS)
if timezone.now() >= settings_obj.access_token_expires_at - margin:
_refresh_access_token(settings_obj)
return {"params": {"access_token": settings_obj.access_token}}
return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}}
def verify_purchase(product_id: str, purchase_token: str) -> bool:
@@ -36,14 +145,16 @@ def verify_purchase(product_id: str, purchase_token: str) -> bool:
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
return False
url = f"{BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
try:
response = requests.get(
url,
params={"access_token": settings_obj.access_token},
timeout=REQUEST_TIMEOUT_SECONDS,
)
auth_kwargs = _auth_kwargs_for_request(settings_obj)
except BazaarAuthError as exc:
logger.error(f"❌ احراز هویت کافه‌بازار ناموفق: {exc}")
return False
try:
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
response.raise_for_status()
body = response.json()
purchase_state = body.get("purchaseState")
@@ -0,0 +1,57 @@
# apps/billing/providers/dynamic_discount.py
"""
تولید توکن «تخفیف پویا» کافه‌بازار
(developers.cafebazaar.ir/fa/guidelines/in-app-billing/dynamic-discount).
فقط یک تابع خام است، بدون endpoint/کمپین — چون فعلا کمپین تخفیف مشخصی در
کار نیست؛ هر وقت لازم شد (مثلا یک ویو که برای یک GemPack با قیمت پایین‌تر
توکن می‌سازد)، همین تابع را صدا بزن. کلید امضا (BazaarSettings.
dynamic_discount_secret) عمدا فقط همین‌جا (سمت سرور) استفاده می‌شود و
هیچ‌وقت نباید به کلاینت/اپ فلاتر فرستاده شود — طبق مستندات، ذخیره‌ی این کلید
در اپ یعنی افشای آن، و کافه‌بازار مسئولیتی در قبال سوءاستفاده نمی‌پذیرد.
خروجی این تابع مستقیم در فیلد dynamicPriceToken کلاس PurchaseRequest
(سمت اپ، هنگام FlutterPoolakey.purchase) استفاده می‌شود.
"""
import time
import uuid
import jwt
from apps.billing.models import BazaarSettings
class DynamicDiscountNotConfigured(Exception):
"""کلید امضای تخفیف پویا در BazaarSettings تنظیم نشده."""
def generate_dynamic_discount_token(
*,
sku: str,
price_rial: int,
ttl_seconds: int = 300,
account_id: str | None = None,
) -> str:
"""یک توکن یک‌بارمصرف طبق مشخصات مستندات کافه‌بازار می‌سازد.
price_rial باید <= قیمت ثبت‌شده‌ی همین sku در پنل پیشخان باشد، وگرنه
کافه‌بازار خودش موقع خرید خطای «قیمت بیش از حد مجاز» (کد ۱۰) برمی‌گرداند."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.dynamic_discount_secret or not settings_obj.package_name:
raise DynamicDiscountNotConfigured(
"dynamic_discount_secret یا package_name در تنظیمات کافه‌بازار خالی است."
)
now = int(time.time())
payload = {
"price": price_rial,
"package_name": settings_obj.package_name,
"sku": sku,
"exp": now + ttl_seconds,
"nonce": uuid.uuid4().hex,
}
if account_id:
payload["account_id"] = account_id
return jwt.encode(payload, settings_obj.dynamic_discount_secret, algorithm="HS256")