adding
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user