Bazaar: subscriptions (subscribeProduct), dynamic price token, RSA key; Zarinpal and Bazaar side by side at checkout
- Checkout sheet always shows both methods as two tiles; Bazaar tile disabled with the reason
(not in Bazaar / Android app only / update the app) instead of hidden; default is Bazaar inside an app that can buy.
- Bridge v2: TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken); inAndroidApp() split
from canBuyFromBazaar() so the pay.html workaround still applies to the installed APK.
- Bazaar flow: order -> optional discount code -> POST /payment/bazaar/orders/{id}/start -> Poolakey -> verify;
consume only packs.
- Android: RSA key, subscribeProduct/getSubscribedProducts for subscriptions, dynamicPriceToken, versionCode 2.
compileDebugKotlin green; no purchase tested on a phone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش میشوند، نه در لحظهی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
|
||||
| `isPaymentPage` + `paysInApp()` | درگاهِ زرینپال داخلِ خودِ اپ باز میشود. اپِ فعلی آن را به مرورگرِ گوشی میدهد که Referer نمیفرستد و زرینپال بیReferer «دسترسی از این دامنه مجاز نمیباشد» میدهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش میزند | «پرداخت» با کارتِ بانکی: صفحهی زرینپال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمهی برگشت وسطِ صفحهی بانک چه میکند را هم ببین |
|
||||
| اسپلشِ یکمرحلهای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه میشود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحهی واقعیِ وب میماند و مستقیم میرود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بینام). فرانت `appReady` را بعد از اولین صفحهی غیرboot صدا میزند و APKِ قدیمی آن را ندارد، پس بیاثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بیسفیدی و بیتصویرِ دوم؛ اندازهی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
|
||||
| خریدِ کافهبازار: کلیدِ RSA، `bazaarBuy` با اشتراک (`subscribeProduct`) و بسته، «تخفیف پویا»؛ `versionCode` ۲ | بیاینها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سهماهه: کاشیِ «کافهبازار» فعال و پیشفرض باشد؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداختشده». یک بار هم «انصراف» در صفحهی بازار |
|
||||
| صفحهی بیاتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحهی خطای WebView یا متنِ فنی نمیبیند. اتصالِ کند (بیخطا) عمداً هیچ متنِ اضافه نمیآورد و روی اسپلش میماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
|
||||
|
||||
**اسپلش و بیاینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصبشده امتحان شد).** چیزی که روی گوشی دیده میشود
|
||||
@@ -47,16 +48,19 @@
|
||||
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
|
||||
ایران بیVPN ۴۰۴ میدهد، `~/.gradle/init.d/google-mirror.gradle` آینهی Aliyun را اضافه میکند؛ ساختِ اول ~۹ دقیقه.
|
||||
|
||||
## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت)
|
||||
## پیش از build — آنچه از پیشخانِ بازار لازم است
|
||||
|
||||
| چه | کجا گذاشته میشود | وضعیت |
|
||||
| چه | کجا گذاشته میشود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|
||||
|---|---|---|
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app` (APKِ ساختهشده)؛ ثبتش در پیشخان تأیید نشده |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمیشود |
|
||||
| SKUِ هر بسته (محصولِ **مصرفشدنی**) | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچکدام تعریف نشده |
|
||||
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصولهای ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
|
||||
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
|
||||
| SKUِ هر پلن | پنلِ ادمینِ بکاند: `/admin/subscription/plan/` ← «Bazaar sku» | سهماهه `10343`، ششماهه `10344` (migrationِ `subscription.0014`)؛ یکماهه و بستهها ندارند؛ `10342` در بازار هست ولی معلوم نیست چیست |
|
||||
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
|
||||
|
||||
قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از
|
||||
بازار نمیخواند.
|
||||
خرید: فرانت `TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)` را صدا میزند؛ اشتراک با
|
||||
`subscribeProduct` (بیconsume، بازار خودش تمدید میکند)، بسته با `purchaseProduct` و بعد `bazaarConsume`. قیمتِ هر
|
||||
محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کمشده را امضا میکند.
|
||||
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
|
||||
|
||||
## build
|
||||
|
||||
|
||||
@@ -64,13 +64,15 @@ android {
|
||||
applicationId = "com.tadrisino.math.app"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
// بازار نسخهی تازه را فقط با versionCodeِ بزرگتر میپذیرد؛ هر build که منتشر میشود یکی بالا.
|
||||
versionCode = 2
|
||||
versionName = "1.1.0"
|
||||
|
||||
// URL of the web frontend.
|
||||
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
|
||||
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
|
||||
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درونبرنامهای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
|
||||
// میرود؛ Poolakey با آن امضای خرید را روی گوشی میسنجد. سنجشِ اصلی سمتِ سرور است.
|
||||
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
|
||||
@@ -41,7 +41,7 @@ import org.json.JSONObject
|
||||
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درونبرنامهایِ بازار است.
|
||||
*
|
||||
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
|
||||
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
|
||||
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
|
||||
* TadrisinoAndroid.bazaarConsume(purchaseToken)
|
||||
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
|
||||
* { orderId, error: "canceled" | "failed", message }
|
||||
@@ -218,31 +218,39 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun purchase(sku: String, orderId: String) {
|
||||
/**
|
||||
* productType «inapp» (بسته: purchaseProduct، بعد از تأییدِ سرور consume) یا «subscription» (اشتراکِ
|
||||
* بازار: subscribeProduct، مصرف نمیشود و بازار خودش تمدید میکند). dynamicPriceToken خالی یعنی قیمتِ
|
||||
* پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده (بازارِ ۱۳.۳ به بعد).
|
||||
*/
|
||||
private fun buy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
|
||||
val p = payment
|
||||
if (p == null) {
|
||||
send(orderId, error = "failed", message = "bazaar not connected")
|
||||
return
|
||||
}
|
||||
p.purchaseProduct(
|
||||
registry = activityResultRegistry,
|
||||
request = PurchaseRequest(productId = sku, payload = orderId),
|
||||
) {
|
||||
val subscription = productType == "subscription"
|
||||
val request = PurchaseRequest(
|
||||
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
|
||||
)
|
||||
val callback: ir.cafebazaar.poolakey.callback.PurchaseCallback.() -> Unit = {
|
||||
purchaseSucceed { send(orderId, token = it.purchaseToken) }
|
||||
purchaseCanceled { send(orderId, error = "canceled") }
|
||||
purchaseFailed { recoverOwned(sku, orderId, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, it) }
|
||||
purchaseFailed { recoverOwned(sku, orderId, subscription, it) }
|
||||
failedToBeginFlow { recoverOwned(sku, orderId, subscription, it) }
|
||||
}
|
||||
if (subscription) p.subscribeProduct(activityResultRegistry, request, callback)
|
||||
else p.purchaseProduct(activityResultRegistry, request, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
|
||||
* دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||
* خریدِ قبلیِ همین محصول (بستهی مصرفنشده، یا اشتراکِ فعال) که پولش رفته ولی تأییدِ سرور نرسیده بود
|
||||
* جلوی خریدِ دوباره را میگیرد. فرانت در تلاشِ بعدی همان سفارش را میفرستد؛ اگر توکنِ ماندهٔ همان
|
||||
* سفارش (payload) پیدا شد، همان برمیگردد تا سرور ثبتش کند.
|
||||
*/
|
||||
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
|
||||
private fun recoverOwned(sku: String, orderId: String, subscription: Boolean, cause: Throwable) {
|
||||
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
|
||||
p.getPurchasedProducts {
|
||||
val query: ir.cafebazaar.poolakey.callback.PurchaseQueryCallback.() -> Unit = {
|
||||
querySucceed { owned ->
|
||||
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
|
||||
if (match != null) send(orderId, token = match.purchaseToken)
|
||||
@@ -250,6 +258,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
queryFailed { send(orderId, error = "failed", message = cause.message) }
|
||||
}
|
||||
if (subscription) p.getSubscribedProducts(query) else p.getPurchasedProducts(query)
|
||||
}
|
||||
|
||||
private fun consume(purchaseToken: String) {
|
||||
@@ -273,11 +282,12 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
/** متدهای این کلاس روی threadِ WebView صدا زده میشوند، نه UI. */
|
||||
private inner class Bridge {
|
||||
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
|
||||
@JavascriptInterface
|
||||
fun bazaarPurchase(sku: String, orderId: String) {
|
||||
fun bazaarBuy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
|
||||
runOnUiThread {
|
||||
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
|
||||
purchase(sku, orderId)
|
||||
buy(sku, orderId, productType, dynamicPriceToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user