ویکی بازینس
English
شروع کار

مقدمه

بازینس چه چیزهایی به بازی شما می‌دهد، اجزای آن چطور کنار هم کار می‌کنند و این ویکی چطور مرتب شده است.

بازینس چیست

بازینس پلتفرم بازی گیتا گیمز است. وقتی بازی شما به آن وصل شود، بازیکنان این‌ها را می‌گیرند:

  • حساب بازینس: با شماره موبایل وارد می‌شوند و همان حساب در همه‌ی بازی‌های بازینس کار می‌کند.
  • گیتا کوین: واحد پول پلتفرم. بازیکن آن را در بازی‌ها به دست می‌آورد و برای جایزه‌های واقعی خرج می‌کند.
  • صفحه‌های بازینس داخل بازی شما: ورود، پروفایل، فروشگاه جایزه‌ی «انتخاب جایزه» و صفحه‌ی پیشنهاد مخصوص بازی شما.
  • پرداخت بازینس: خرید با پول واقعی از طریق صفحه‌ی پرداخت بازینس.

این‌ها دو یکپارچه‌سازی جدا از هم هستند. می‌توانید هر کدام را به‌تنهایی یا هر دو را با هم اضافه کنید:

یکپارچه‌سازی چیزی که بازیکن می‌گیرد چه کسی با بازینس حرف می‌زند چیزهایی که لازم دارید
پلتفرم بازینس حساب، گیتا کوین، صفحه‌های بازینس سرور شما API پلتفرم را صدا می‌زند؛ کلاینت شما صفحه‌ها را نشان می‌دهد و موجودی سکه را می‌خواند شناسه‌ی کلاینت، کلید محرمانه، نام بسته‌ی پلتفرم
پرداخت بازینس خرید با پول واقعی سرور شما API پرداخت را صدا می‌زند؛ کلاینت شما صفحه‌ی پرداخت را باز می‌کند نام بسته‌ی پرداخت، SKUهای ثبت‌شده در داشبورد بازینس

اجزا چطور کنار هم کار می‌کنند

flowchart LR
  subgraph Device["Player device"]
    G["Your game client"]
    S["Baziness sheet<br/>iframe → baziness.com"]
    P["Payment tab<br/>pay.baziness.com/buy"]
  end
  subgraph Backend["Your backend"]
    B["Game server<br/>(holds clientId + clientSecret)"]
  end
  K[("Baziness platform API")]
  Y[("Baziness Pay API")]
  G -- "/api/platform/*" --> B
  G -- "/api/shop/purchase/*" --> B
  B -- "client/auth · create-user · add-coin …" --> K
  B -- "get_payment · verify_payment · consume" --> Y
  G -- "user/get-coin (user token)" --> K
  G -- "opens" --> S
  S -- "postMessage: login · logout · close" --> G
  G -- "opens" --> P

کلاینت فقط در دو مورد مستقیم با بازینس حرف می‌زند: صفحه‌های بازینس را نشان می‌دهد، و موجودی سکه را با توکن خود بازیکن می‌خواند (user/get-coin). همه‌ی کارهای دیگر از سرور شما می‌گذرند، چون فقط سرور شما کلید محرمانه را دارد.

پنج قانون طلایی

  1. کلید محرمانه هیچ‌وقت از سرور بیرون نمی‌رود. مرورگر یا اپ فقط تنظیمات عمومی و توکن خود بازیکن را می‌گیرد.
  2. سرور تصمیم می‌گیرد و کلاینت فقط نمایش می‌دهد. اتصال حساب، دادن سکه و خرید همیشه در سرور بررسی می‌شوند.
  3. هر لینک صفحه‌ی بازینس packageName و sdkType=js دارد. بدون sdkType=js صفحه نمی‌تواند با بازی حرف بزند و حتی نمی‌تواند خودش را ببندد.
  4. بازی بدون بازینس هم کار می‌کند. اگر پلتفرم خاموش یا در دسترس نبود، بخش‌های بازینس را پنهان کنید و ادامه دهید.
  5. هر پرداخت یک شناسه‌ی یکتا و تکرارپذیر دارد. تلاش دوباره، دو بار زدن دکمه یا کرش سرور هیچ‌وقت نباید دو بار پاداش بدهد.

محیط‌ها

توسعه (بازی‌های تستی) اصلی
API پلتفرم https://platform-dev.karizmastudios.org/api https://platform.karizmastudio.com/api
صفحه‌های بازینس https://platform-web-dev.karizmastudios.org https://baziness.com
پرداخت بازینس https://pay-dev.karizmastudios.org https://pay.baziness.com
ورود هر شماره موبایلی، کد 111111 (پیامکی فرستاده نمی‌شود) شماره‌ی واقعی و کد پیامکی
خرید تستی «خرید آزمایشی» در صفحه‌ی پرداخت (بدون پرداخت واقعی) پرداخت واقعی

هر محیط شناسه و کلید محرمانه‌ی خودش را دارد.

این ویکی چطور مرتب شده

بخش محتوا کد دارد؟
شروع کار همین صفحه و یکپارچه‌سازی قدم‌به‌قدم نه
API همه‌ی endpointها به ترتیب: چه کار می‌کند، کِی صدایش بزنید، درخواست، پاسخ و قانون‌ها فقط درخواست و پاسخ
راهنماها جریان‌ها: ورود، گیتا کوین، صفحه‌ی جایزه، صفحه‌ی پیشنهاد آموزش، لینک‌ها نه
نمونه کد کد آماده‌ی سرور (TypeScript، C#، Python) و کلاینت (وب، React، Cocos Creator، Unity)، از جمله وب‌ویو بله
بازی نمونه یک بازی کامل و متن‌باز که همه‌ی این‌ها را دارد: اجرا و دانلود کل پروژه
مرجع چک‌لیست انتشار، رفع اشکال و همه‌ی endpointها در یک جدول نه

هر صفحه را با دکمه‌های زیر عنوانش به‌صورت Markdown یا PDF دانلود کنید. راهنمای کامل هم در هر دو قالب در نوار کناری هست.

شروع کار

شروع کار، قدم‌به‌قدم

چه چیزهایی از تیم پلتفرم بگیرید، هر مقدار کجا استفاده می‌شود، و کل یکپارچه‌سازی در چند قدم کوتاه که هر کدام به جزئیاتش لینک دارد.

۱. کلیدها را بگیرید

تیم پلتفرم بازینس این مقدارها را به شما می‌دهد، برای هر محیط یک دسته (توسعه و اصلی):

مقدار مثال چه کسی استفاده می‌کند محرمانه؟
شناسه‌ی کلاینت (client id) 12 سرور شما، برای گرفتن توکن کلاینت نه، ولی خصوصی نگهش دارید
کلید محرمانه (client secret) •••••••• سرور شما، برای گرفتن توکن کلاینت بله، فقط سرور
نام بسته‌ی پلتفرم com.karizmastudio.example لینک همه‌ی صفحه‌های بازینس (packageName) نه
نام بازی example صفحه‌ی جایزه و صفحه‌ی پیشنهاد (game_name) نه
نام بسته‌ی پرداخت com.karizmastudio.example همه‌ی درخواست‌های پرداخت (pname)؛ ممکن است با نام بسته‌ی پلتفرم فرق کند نه
SKUها example_pack محصولاتی که با پرداخت بازینس می‌فروشید نه

هشدار

کلید محرمانه هیچ‌وقت نباید به مرورگر یا بیلد اپ برسد: نه در کد کلاینت، نه در متغیرهای VITE_* یا NEXT_PUBLIC_*، نه در گیت. هر کسی که آن را داشته باشد می‌تواند به نام بازی شما بازیکن بسازد و گیتا کوین بدهد.

۲. دو نوع توکن را بشناسید

همه‌چیز در پلتفرم با یکی از این دو توکن کار می‌کند. جدا نگه داشتن این دو، کلید کل یکپارچه‌سازی است:

توکن چه کسی دارد چطور گرفته می‌شود برای چه
توکن کلاینت فقط سرور شما شناسه + کلید محرمانه ← client/auth همه‌ی درخواست‌های سرور به سرور: ساختن بازیکن، بررسی توکن، دادن سکه
توکن کاربر کلاینت بازیکن (از سرور شما) client/create-user یا client/get-user-token باز کردن صفحه‌های بازینس به نام همین بازیکن (linkedToken) و خواندن موجودی سکه‌اش

۳. قدم‌ها را دنبال کنید

# قدم کجا جزئیات
۱ سرور شما با شناسه و کلید محرمانه یک توکن کلاینت می‌گیرد سرور API پلتفرم ← قدم ۱
۲ اولین باری که بازیکن می‌آید، سرور کاربر بازینس او را می‌سازد و شناسه‌اش را ذخیره می‌کند. دفعه‌های بعد توکن تازه می‌گیرد سرور قدم‌های ۲ و ۳
۳ کلاینت صفحه‌های بازینس (ورود، پروفایل، جایزه) را با توکن کاربر نشان می‌دهد و به پیام‌هایشان گوش می‌دهد کلاینت صفحه‌های بازینس
۴ وقتی بازیکن در صفحه وارد شد، سرور توکن را بررسی می‌کند و حساب را وصل می‌کند (یا به حساب دیگر بازیکن جابه‌جا می‌کند) سرور ورود، قدم ۴
۵ کلاینت موجودی گیتا کوین را نشان می‌دهد و سرور برای برد و جایزه گیتا کوین می‌دهد هر دو گیتا کوین، قدم‌های ۵ و ۶
۶ دکمه‌ی «انتخاب جایزه» صفحه‌ی جایزه را باز می‌کند؛ آموزش می‌تواند صفحه‌ی پیشنهاد بازی را باز کند کلاینت صفحه‌ی جایزه، صفحه‌ی پیشنهاد آموزش
۷ آیتم‌ها را با پرداخت بازینس بفروشید هر دو پرداخت بازینس
۸ لینک‌هایی را که بازیکن می‌آورند با پارامترهای UTM برچسب بزنید هر دو لینک‌ها و UTM

کد آماده‌ی همه‌ی قدم‌ها در کد سرور و کد کلاینت است. بازی نمونه همه را کنار هم دارد.

۴. سرور را تنظیم کنید

بازی نمونه و نمونه کدها این تنظیمات را می‌خوانند. همین نام‌ها را نگه دارید تا نمونه‌ها بدون تغییر کار کنند:

تنظیم مثال معنی
BAZINESS_PLATFORM_ENABLED true کلید اصلی. تا وقتی false است بازی هیچ بخش بازینسی نشان نمی‌دهد
BAZINESS_PLATFORM_USE_PRODUCTION false false = آدرس‌های توسعه، true = آدرس‌های اصلی
BAZINESS_CLIENT_ID 12 شناسه‌ی کلاینت
BAZINESS_CLIENT_SECRET •••• کلید محرمانه (محرمانه)
BAZINESS_PLATFORM_PACKAGE_NAME com.karizmastudio.example packageName در لینک صفحه‌ها
BAZINESS_GAME_NAME example game_name در صفحه‌ی جایزه و پیشنهاد
BAZINESS_PAY_ENABLED true دکمه‌های خرید را روشن می‌کند
BAZINESS_PAY_BASE_URL https://pay-dev.karizmastudios.org سرور پرداخت این محیط
BAZINESS_PAY_PACKAGE_NAME com.karizmastudio.example pname در همه‌ی درخواست‌های پرداخت
BAZINESS_SKU_PREFIX (خالی) اختیاری: SKU = پیشوند + شناسه‌ی محصول

پلتفرم فقط وقتی روشن حساب می‌شود که BAZINESS_PLATFORM_ENABLED=true باشد و هم شناسه و هم کلید محرمانه تنظیم شده باشند. به این ترتیب سروری که نیمه‌کاره تنظیم شده، دقیقاً مثل وقتی رفتار می‌کند که بازینس اصلاً وجود ندارد.

API

API پلتفرم، قدم‌به‌قدم

همه‌ی endpointهای پلتفرم به ترتیبی که استفاده‌شان می‌کنید: چه کاری می‌کند، کِی صدایش بزنید، چه بفرستید و چه برمی‌گردد. بدون کد؛ کد آماده در بخش «نمونه کد» است.

پیش از شروع

مهم

توکن‌های کاربر در حال تغییر به توکن دسترسی ۱۵ دقیقه‌ای، توکن تمدید روی سرور شما و کد اتصال یک‌بارمصرف هستند. قدم‌های زیر در دوره‌ی مهاجرت همچنان کار می‌کنند. چه چیزی را تا کی عوض کنید: مهاجرت توکن.

آدرس پایه {api} توسعه: https://platform-dev.karizmastudios.org/api
اصلی: https://platform.karizmastudio.com/api
قالب بدنه‌ی درخواست و پاسخ JSON است (Content-Type: application/json) و نام فیلدها camelCase
چه کسی صدا می‌زند سرور شما، به جز قدم ۶ (user/get-coin) که کلاینت بازیکن صدا می‌زند
احراز هویت قدم‌های ۲ تا ۵ هدر Authorization: Bearer <توکن کلاینت> از قدم ۱ را می‌خواهند؛ قدم ۶ توکن کاربر بازیکن را

کل ترتیب برای یک بازیکن جدید:

sequenceDiagram
  autonumber
  participant S as Your server
  participant K as Platform API
  participant C as Player's client
  S->>K: client/auth (client id + secret)
  K-->>S: client token
  S->>K: client/create-user (device id)
  K-->>S: userId + user token
  S-->>C: user token
  C->>K: user/get-coin (user token)
  K-->>C: balance
  Note over C: the player signs in on a Baziness page → the page gives the client a token
  C->>S: that token
  S->>K: client/validate-user-token
  K-->>S: userId (who signed in)
  S->>K: client/add-coin (when the player wins)
  K-->>S: new balance

قدم ۱ — گرفتن توکن کلاینت

چه کار می‌کند: شناسه‌ی کلاینت و کلید محرمانه را با یک توکن کلاینت عوض می‌کند. همه‌ی درخواست‌های دیگر سرور (قدم‌های ۲ تا ۵) این توکن را در هدر Authorization می‌فرستند.

کِی صدایش بزنید: با اولین درخواست به پلتفرم بعد از بالا آمدن سرور، و دوباره هر وقت درخواستی پاسخ 401 داد، چون توکن منقضی شده است.

HTTP
POST {api}/client/auth
Content-Type: application/json

{ "clientId": 12, "clientSecret": "••••••••••••" }

→ 200 OK
{ "clientId": 12, "token": "eyJhbGciOiJIUzI1NiIs…" }

قانون‌ها

  • توکن را در حافظه‌ی سرور نگه دارید و برای همه‌ی درخواست‌ها از همان استفاده کنید؛ برای هر درخواست توکن تازه نگیرید.
  • اگر درخواستی پاسخ 401 Unauthorized داد، دوباره client/auth را صدا بزنید و همان درخواست را یک بار دیگر بفرستید.
  • توکن کلاینت به اندازه‌ی کلید محرمانه حساس است: هرگز آن را به کلاینت نفرستید.

قدم ۲ — ساختن کاربر بازینس برای بازیکن

چه کار می‌کند: برای یکی از بازیکنان شما یک کاربر بازینس می‌سازد و این‌ها را برمی‌گرداند:

  • userId بازینس: آن را روی رکورد بازیکن ذخیره کنید؛ برای همه‌ی درخواست‌های بعدی لازم است
  • یک توکن کاربر برای این بازیکن: آن را به کلاینت بفرستید
  • isLinkedToAccount: آیا این کاربر با شماره موبایل وارد شده (true) یا هنوز ناشناس است (false)

کِی صدایش بزنید: اولین باری که بازیکن شما به بازینس نیاز دارد؛ معمولاً اولین اجرای بازی وقتی پلتفرم روشن است. اگر از قبل userId بازیکن را دارید، به جای این قدم ۳ را صدا بزنید.

HTTP
POST {api}/client/create-user
Authorization: Bearer <client token>
Content-Type: application/json

{ "deviceId": "8f0c2b7e-…", "platform": "Web", "store": "Direct", "linkedToken": null }

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": false }
فیلد مقدار
deviceId شناسه‌ی ثابت بازیکن شما، مثلاً شناسه‌ی دستگاهی که بازی در اولین اجرا ساخته
platform Web، Android، Ios یا Editor
store Direct (وب)، GooglePlay، AppStore، CafeBazaar یا Myket
linkedToken توکن کاربری که بازیکن از قبل دارد، یا null. اپ بازینس بازی‌ها را با ?linkedToken=… باز می‌کند؛ فرستادن آن در این‌جا بازیکن را به همان حساب بازینس وصل می‌کند

مهم

اگر userId برگشتی از قبل روی بازیکن دیگری از بازیکنان شما ذخیره شده، این همان شخص است، مثلاً روی گوشی جدید. آن را دوباره ذخیره نکنید؛ به جایش این دستگاه را به آن بازیکن جابه‌جا کنید (ورود ← جابه‌جایی حساب). روی ستونی که userId را نگه می‌دارد محدودیت یکتا بگذارید.

قدم ۳ — گرفتن توکن تازه برای کاربر

چه کار می‌کند: برای کاربر بازینسی که از قبل می‌شناسید یک توکن تازه و مقدار فعلی isLinkedToAccount را برمی‌گرداند.

کِی صدایش بزنید: در هر جلسه‌ی بعدی بازیکن، معمولاً هر بار که کلاینت بالا می‌آید. به این ترتیب کلاینت همیشه توکن معتبر دارد، و شما می‌فهمید کِی یک کاربر ناشناس وارد حسابش شده (isLinkedToAccount شده true).

HTTP
GET {api}/client/get-user-token/239236
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true }

قدم ۴ — بررسی توکنی که کلاینت فرستاده

چه کار می‌کند: می‌گوید یک توکن کاربر واقعی است یا نه، و مال کدام کاربر بازینس است.

کِی صدایش بزنید: هر وقت توکنی از کلاینت رسید و می‌خواهید بر اساسش کاری بکنید. مورد اصلی: بازیکن در صفحه‌ی ورود بازینس وارد می‌شود، صفحه یک توکن به کلاینت می‌دهد و کلاینت آن را به سرور می‌فرستد. هیچ‌وقت بر اساس توکنی که بررسی نکرده‌اید حسابی وصل نکنید یا چیزی ندهید.

HTTP
GET {api}/client/validate-user-token/eyJhbGciOiJIUzI1NiIs…
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "isValid": true }

→ 200 OK   (an invalid or expired token is NOT an HTTP error)
{ "userId": 0, "isValid": false }

هشدار

توکن نامعتبر خطای HTTP نیست: پاسخ 200 با "isValid": false و "userId": 0 است. همیشه isValid را بررسی کنید.

قدم ۵ — دادن گیتا کوین

چه کار می‌کند: به موجودی بازینس بازیکن گیتا کوین اضافه می‌کند و موجودی جدید را برمی‌گرداند.

کِی صدایش بزنید: وقتی بازیکن در بازی شما سکه به دست می‌آورد؛ مثلاً با بردن یک مسابقه، رتبه‌ی تورنمنت یا باز کردن هدیه.

HTTP
POST {api}/client/add-coin
Authorization: Bearer <client token>
Content-Type: application/json

{
  "userId": 239236,
  "amount": 50,
  "transactionId": "239236_50_9f2c1a7d0b4e8c3f5a6d2e1b",
  "metaData": { "source": "match_win", "matchId": 17 }
}

→ 200 OK
{ "resourceId": 1, "amount": 2450 }          ← the player's new balance

→ 400 Bad Request   (this transactionId was already credited)
{ "success": false, "error": "add resource failed" }
فیلد مقدار
userId شناسه‌ی کاربر بازینس از قدم ۲
amount تعداد سکه (مثبت)
transactionId یک شناسه‌ی یکتا برای همین پرداخت (پایین را ببینید). پلتفرم هر شناسه را فقط یک بار واریز می‌کند
metaData هر JSON که می‌خواهید کنار تراکنش ذخیره شود (منبع، شناسه‌ی مسابقه و …)، برای گزارش‌ها

امن کردن transactionId: آن را از ماهیت پرداخت بسازید، نه از زمان:

Text
transactionId = <userId>_<amount>_<first 24 hex chars of sha1(grantKey)>
grantKey      = "match:17" | "reward_claim:42" | "payment:88:GITA_COIN" | "tutorial_gift:<userId>"
  • یک پرداخت همیشه یک شناسه‌ی ثابت می‌سازد. تلاش دوباره، دو بار زدن دکمه یا درخواست تکراری با 400 رد می‌شود و هیچ‌وقت دو بار پرداخت نمی‌شود.
  • این 400 را به معنی «قبلاً پرداخت شده» بگیرید و حساب‌داری خودتان را تمام کنید، مثلاً جایزه را دریافت‌شده علامت بزنید. وگرنه کرشی بین واریز و ذخیره‌ی شما بازیکن را پشت یک خطا گیر می‌اندازد.
  • شناسه‌ای که از ساعت ساخته شود (<userId>_<amount>_<time>) با هر بار صدا زدن دوباره واریز می‌کند؛ فقط برای پرداخت‌هایی که واقعاً تکرارشونده‌اند.

قدم ۶ — خواندن موجودی (کلاینت)

چه کار می‌کند: موجودی گیتا کوین بازیکن را برمی‌گرداند. تنها درخواست پلتفرم است که کلاینت با توکن کاربر بازیکن می‌زند. درخواست از دامنه‌های دیگر مجاز است، پس بازی مرورگری می‌تواند مستقیم صدایش بزند.

کِی صدایش بزنید: بعد از اینکه کلاینت توکن گرفت، وقتی یک صفحه‌ی بازینس بسته می‌شود (شاید بازیکن سکه خرج کرده باشد)، وقتی بازی دوباره به جلو می‌آید، و بعد از اینکه سرور سکه داد.

HTTP
GET {api}/user/get-coin
Authorization: Bearer <user token>

→ 200 OK
{ "resourceId": 1, "amount": 2450 }

خطاها در یک نگاه

پاسخ کجا معنی کار شما
401 Unauthorized قدم‌های ۲ تا ۵ توکن کلاینت نیست یا منقضی شده قدم ۱ را دوباره صدا بزنید و یک بار دیگر بفرستید
200 با "isValid": false قدم ۴ توکن واقعی نیست یا منقضی شده درخواست را رد کنید؛ کلاینت باید دوباره وارد شود
400 با "add resource failed" قدم ۵ این transactionId قبلاً واریز شده آن را پرداخت‌شده حساب کنید
خطای شبکه / 5xx هر جا پلتفرم در دسترس نیست بازی را بدون بخش‌های بازینس ادامه دهید و بعداً دوباره تلاش کنید
API

صفحه‌های بازینس و پیام‌هایشان

صفحه‌های وبی که بازی شما داخل خودش نشان می‌دهد (ورود، پروفایل، فروشگاه جایزه و صفحه‌ی پیشنهاد بازی)، پارامترهایی که هر کدام لازم دارد و پیام‌هایی که برمی‌گردانند.

این صفحه‌ها چه هستند

بعضی بخش‌های بازینس صفحه‌ی وب هستند که بازی شما داخل خودش نشان می‌دهد؛ مثلاً صفحه‌ی ورود و فروشگاه جایزه‌ی «انتخاب جایزه». آدرس صفحه را در یک وب‌ویوی داخل بازی باز می‌کنید: روی وب یک iframe، در موتور بازی یک کامپوننت WebView. صفحه با پیام با بازی حرف می‌زند، مثلاً «بازیکن وارد شد» یا «مرا ببند».

همه‌ی صفحه‌ها روی یک سایت هستند:

محیط آدرس پایه‌ی صفحه‌ها {web}
توسعه https://platform-web-dev.karizmastudios.org
اصلی https://baziness.com

ساختن نمای داخل بازی (شیتی که از پایین بالا می‌آید و با کشیدن به پایین بسته می‌شود) در شیت پایین / وب‌ویو آمده است.

پارامترها

مهم

linkCode (کد یک‌بارمصرف از سرور شما با v2/client/link-code/{userId}) جای linkedToken را می‌گیرد. در دوره‌ی مهاجرت صفحه‌ها هر دو را قبول می‌کنند. مهاجرت توکن را ببینید.

پارامتر کِی مقدار چرا
packageName همیشه نام بسته‌ی پلتفرم شما بازی شما را معرفی می‌کند
sdkType همیشه js به صفحه می‌گوید داخل بازی است تا برایتان پیام بفرستد. بدون آن حتی دکمه‌ی بستن خود صفحه هم نمای شما را نمی‌بندد
linkedToken هر وقت دارید توکن کاربر بازیکن صفحه به نام همین بازیکن کار می‌کند و ورود داخل صفحه به او وصل می‌شود
redirect پروفایل، جایزه profile / shop صفحه بعد از ورود داخلش به کجا برگردد
allowGuest ورود، پروفایل، پیشنهاد بازی false «ادامه به عنوان مهمان» خود صفحه را پنهان می‌کند؛ بازی شما این گزینه را دارد
game_name جایزه، پیشنهاد بازی نام بازی شما، URL-encode شده جایزه‌ها و پیشنهاد بازی شما را انتخاب می‌کند
buildStore فقط بیلدهای فروشگاهی CafeBazaar / Myket صفحه لینک فروشگاه‌های دیگر را پنهان می‌کند

همه‌ی مقدارها را encode کنید، چون game_name ممکن است فارسی باشد و توکن‌ها ممکن است + یا / داشته باشند.

صفحه‌ها

صفحه مسیر کِی بازش کنید پارامترها
ورود / بازیکن «ورود با حساب بازینس» را می‌زند، یا برای دریافت جایزه باید وارد شود packageName، sdkType=js، linkedToken، allowGuest=false
پروفایل /profile تنظیمات ← اتصال یا مدیریت حساب packageName، sdkType=js، linkedToken، redirect=profile، allowGuest=false
فروشگاه جایزه «انتخاب جایزه» /shop بازیکن دکمه‌ی جایزه را می‌زند game_name، packageName، sdkType=js، linkedToken، redirect=shop؛ بدون allowGuest
پیشنهاد بازی /game-offer یک بار، وسط آموزش (جزئیات) game_name، packageName، sdkType=js، linkedToken، allowGuest=false
Text
Login        https://baziness.com/?packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&allowGuest=false
Profile      https://baziness.com/profile?packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&redirect=profile&allowGuest=false
Rewards      https://baziness.com/shop?game_name=example&packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&redirect=shop
Game offer   https://baziness.com/game-offer?game_name=example&packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&allowGuest=false

مهم

برای فروشگاه جایزه /shop را باز کنید، نه ریشه‌ی سایت (/). ریشه بازیکنی را که داخل بازی است به صفحه‌ی ورود می‌برد؛ /shop از توکنی که بازیکن دارد استفاده می‌کند.

پیام‌های صفحه‌ها

صفحه با postMessage پیام می‌فرستد. هر پیام JSON است، به شکل رشته یا شیء، با قالب { "type": "...", "data": ... }:

type data معنی کار بازی شما
close — دکمه‌ی بستن صفحه زده شد نما را ببندید
login توکن: "<token>" یا { "token": "<token>" } بازیکن وارد یک حساب شد توکن را به سرور خودتان بفرستید تا بررسی‌اش کند و حساب را وصل کند یا به حساب دیگر بازیکن جابه‌جا شود؛ بعد توکن را تازه کنید و نما را ببندید
login (بدون داده) حساب فعلی بازیکن در همان جا وصل شد بازیکن را متصل علامت بزنید و توکن را تازه کنید
token توکن دست‌دادن بی‌صدایی که صفحه هنگام بارگذاری می‌فرستد نادیده بگیرید؛ ورود نیست
logout — بازیکن داخل صفحه خارج شد نما را ببندید، هر چه بازی به‌صورت محلی ذخیره کرده پاک کنید و مثل یک بازدیدکننده‌ی کاملاً جدید از اول شروع کنید

هشدار

  • فقط پیام‌هایی را بپذیرید که origin آن‌ها صفحه‌ای است که باز کرده‌اید ({web}) و بقیه را نادیده بگیرید.
  • هیچ‌وقت token را ورود حساب نکنید؛ وگرنه فروشگاه جایزه به محض باز شدن بسته می‌شود.
API

پرداخت بازینس، قدم‌به‌قدم

فروش آیتم با پول واقعی، به ترتیب: صفحه‌ی پرداخت را باز کنید، پرداخت را پیدا و تأیید کنید، آیتم را دقیقاً یک بار بدهید و خرید را مصرف کنید تا دوباره قابل خرید باشد.

پیش از شروع

آدرس پایه {pay} توسعه: https://pay-dev.karizmastudios.org
اصلی: https://pay.baziness.com
چه کسی صدا می‌زند سرور شما. کلاینت فقط صفحه‌ی پرداخت را باز می‌کند و بقیه‌ی کارها را با سرور خود شما انجام می‌دهد
pname نام بسته‌ی پرداخت شما؛ ممکن است با نام بسته‌ی پلتفرم فرق کند
sku شناسه‌ی محصولی که در داشبورد بازینس ثبت شده (مثلاً example_pack)
uid شناسه‌ی ثابت بازیکن در بازی شما، و باید عددی باشد (به شکل رشته بفرستید، مثلاً "1042"). uid غیرعددی با no_required_params رد می‌شود
sequenceDiagram
  autonumber
  participant P as Player
  participant C as Your client
  participant S as Your server
  participant Y as Baziness Pay
  P->>C: taps "Buy"
  C->>S: start a purchase
  S-->>C: payment id + payment page URL
  C->>Y: opens the payment page (new tab)
  P->>Y: pays
  P->>C: returns to the game (or taps «پرداخت کردم»)
  C->>S: verify my purchase
  S->>Y: get_payment
  Y-->>S: payment token
  S->>Y: verify_payment
  Y-->>S: success
  S->>S: give the item — exactly once
  S-->>C: done + new balances
  S->>Y: consume

قدم ۱ — باز کردن صفحه‌ی پرداخت

چه کار می‌کند: صفحه‌ی پرداخت بازینس را برای یک محصول به بازیکن نشان می‌دهد.

چطور: وقتی بازیکن «خرید» را می‌زند، کلاینت از سرور شما می‌خواهد خرید را شروع کند. سرور یک سفارش ذخیره می‌کند (قدم ۴ را ببینید)، آدرس صفحه را می‌سازد و برمی‌گرداند. کلاینت آن را در تب جدید (وب) یا مرورگر سیستم (موبایل) باز می‌کند.

HTTP
GET {pay}/buy?sku=example_pack&uid=1042&pname=com.karizmastudio.example

→ the hosted payment page (opened in a new tab / the system browser)

پیشنهاد

مرورگرها تبی را که مستقیم با ضربه‌ی بازیکن باز نشده باشد مسدود می‌کنند. اول در همان رویداد ضربه یک تب خالی باز کنید، بعد وقتی سرور جواب داد آن را به آدرس ببرید (کد کلاینت).

قدم ۲ — بررسی اینکه بازیکن پرداخت کرده یا نه

چه کار می‌کند: از بازینس می‌پرسد این بازیکن برای این محصول پرداخت کامل‌شده و هنوز مصرف‌نشده‌ای دارد یا نه، و اگر دارد توکن پرداخت را برمی‌گرداند.

کِی صدایش بزنید: وقتی بازیکن به بازی برمی‌گردد (پنجره یا اپ دوباره فوکوس می‌گیرد)، وقتی «پرداخت کردم» را می‌زند، و وقتی فروشگاه باز می‌شود، برای پرداخت‌هایی که وقتی بازی بسته بود تمام شده‌اند.

HTTP
POST {pay}/get_payment
Content-Type: application/json

{ "uid": "1042", "sku": "example_pack", "pname": "com.karizmastudio.example" }

→ 200 OK   (paid, not consumed yet)
{ "uid": "1042", "sku": "example_pack", "token": "TEST-633…", "time": 1791366000000, "success": true }

→ 200 OK   (nothing to verify yet — the player hasn't paid)
{ "error": "payment_is_not_valid", "success": false }

payment_is_not_valid یعنی «هنوز پرداختی نیست»: بی‌صدا منتظر بمانید.

قدم ۳ — تأیید پرداخت

چه کار می‌کند: از بازینس تأیید می‌گیرد که توکن پرداخت واقعی است. این همان درخواستی است که از شما محافظت می‌کند: هیچ‌وقت بر اساس حرف کلاینت چیزی ندهید.

HTTP
POST {pay}/verify_payment
Content-Type: application/x-www-form-urlencoded

uid=1042&token=TEST-633…&sku=example_pack&pname=com.karizmastudio.example

→ 200 OK
{ "success": true }

هشدار

verify_payment بدنه را به شکل فرم (application/x-www-form-urlencoded) می‌گیرد؛ بقیه‌ی درخواست‌ها JSON. فرستادن JSON در این‌جا رایج‌ترین دلیل شکست تأیید است.

قدم ۴ — دادن آیتم، دقیقاً یک بار

بازینس به شما می‌گوید پرداخت واقعی است؛ اینکه بازیکن آیتم را یک بار بگیرد کار سرور شماست. یک جدول پرداخت‌ها در دیتابیس نگه دارید:

ستون معنی
id شناسه‌ی سفارش شما (در قدم ۱ به کلاینت برمی‌گردد)
user_id، product_id، sku، price چه کسی چه چیزی می‌خرد
status REDIRECTED (صفحه‌ی پرداخت باز شد) ← FULFILLED (آیتم داده شد)، یا FAILED / CANCELLED
reward_snapshot دقیقاً همان چیزی که هنگام زدن «خرید» به بازیکن وعده داده شد
provider_token توکن پرداخت از قدم ۲، با محدودیت یکتا

بعد از قدم ۳ موفق، در یک تراکنش دیتابیس:

  1. ردیف سفارش را قفل کنید و ببینید از قبل FULFILLED نباشد. اگر هست، بدون دادن دوباره‌ی آیتم «انجام شد» را برگردانید.
  2. اگر همین توکن پرداخت قبلاً سفارش دیگری را تحویل داده، این یکی را FAILED کنید و متوقف شوید.
  3. آیتم‌های reward_snapshot را بدهید، نه تعریف فعلی محصول را. مدیری که وسط پرداخت بازیکن یک بسته را ویرایش می‌کند نباید سفارش او را عوض کند.
  4. توکن را ذخیره کنید و سفارش را FULFILLED کنید.

قدم ۵ — مصرف کردن خرید

چه کار می‌کند: خرید را مصرف‌شده علامت می‌زند تا همان محصول دوباره قابل خرید باشد. تا وقتی مصرف نشده، قدم ۲ همچنان آن را برمی‌گرداند و خرید دوم آن SKU مسدود است.

کِی: درست بعد از ثبت نهایی قدم ۴. اگر شکست خورد چند بار دوباره تلاش کنید؛ آیتم قبلاً داده شده، پس شکست این قدم هیچ‌وقت بازیکن را معطل نمی‌کند.

HTTP
POST {pay}/consume
Content-Type: application/json

{ "uid": "1042", "sku": "example_pack", "pname": "com.karizmastudio.example" }

→ 200 OK
{ "success": true }

چیزی که بازیکن می‌بیند

لحظه کلاینت شما نشان می‌دهد
بعد از زدن «خرید» یک پنجره‌ی کوچک: «صفحه‌ی پرداخت بازینس باز شد، بعد از پرداخت به بازی برگرد»، با دکمه‌ی «پرداخت کردم»
برگشت به بازی، هنوز پرداختی پیدا نشده چیز تازه‌ای نشان نمی‌دهد (بررسی بی‌صدا)؛ فقط زدن «پرداخت کردم» می‌گوید «پرداختی پیدا نشد، چند لحظه بعد دوباره بررسی کن»
پرداخت تأیید شد «خرید موفق» و موجودی‌های به‌روز
پنجره پیش از پرداخت بسته شد چیزی لغو نمی‌شود؛ دفعه‌ی بعد که فروشگاه باز شود دوباره بررسی می‌شود

تست

  • روی pay-dev صفحه‌ی پرداخت گزینه‌ی «خرید آزمایشی» دارد: کل جریان، از قدم ۱ تا ۵، بدون پول واقعی اجرا می‌شود.
  • اگر به هیچ درگاهی دسترسی ندارید، از درگاه آزمایشی محلی بازی نمونه استفاده کنید: همان پروتکل را دارد، با دکمه‌ی «پرداخت موفق» (تنظیمات).

بیلدهای فروشگاهی (بازار / مایکت)

بیلدی که در کافه‌بازار یا مایکت منتشر می‌شود باید از پرداخت درون‌برنامه‌ای خود آن فروشگاه استفاده کند، نه پرداخت بازینس. نام SKUها را در هر دو یکی نگه دارید تا یک فهرست محصول برای همه‌ی بیلدها کافی باشد، و در آن بیلدها buildStore را روی لینک صفحه‌های بازینس بفرستید (پارامترها).

راهنماها

ورود و اتصال حساب بازینس

بازیکن چطور هویت بازینس می‌گیرد، از اسپلش یا تنظیمات وارد می‌شود، و بازی چطور حساب‌ها را بی‌خطر جابه‌جا می‌کند.

مدل هویت

بازی شما حساب‌های بازیکن خودش را دارد، مثلاً با کلید شناسه‌ی دستگاهی که در اولین اجرا ساخته می‌شود. بازینس روی همین حساب سوار می‌شود:

  1. اولین باری که پلتفرم روشن است، سرور شما یک کاربر ناشناس بازینس برای بازیکن می‌سازد (create-user) و userId آن را روی رکورد بازیکن ذخیره می‌کند.
  2. بازیکن تا وقتی با شماره موبایلش در یکی از صفحه‌های بازینس وارد نشود ناشناس می‌ماند (isLinkedToAccount: false). بعد از ورود حساب متصل است.
  3. اگر حساب بازینسی که بازیکن واردش می‌شود از قبل مال بازیکن دیگری از بازیکنان شما باشد (همان شخص روی گوشی دیگر)، بازی به آن بازیکن جابه‌جا می‌شود و حساب را دو بار وصل نمی‌کند.
وضعیت userId بازینس ذخیره شده؟ isLinkedToAccount چیزی که بازیکن می‌بیند
بازینس خاموش — — هیچ بخش بازینسی
مهمان بله (کاربر ناشناس) false موجودی سکه و دکمه‌ی جایزه کار می‌کنند؛ «اتصال حساب» در تنظیمات
وارد شده بله true همه‌چیز، از جمله دریافت جایزه‌های گیتا کوین

کل جریان

sequenceDiagram
  autonumber
  participant C as Game client
  participant S as Game server
  participant K as Platform API
  participant W as Baziness page (sheet)
  C->>S: GET /api/platform/config
  S-->>C: { enabled, webBase, apiBase, packageName }
  Note over C: Splash: Sign in with Baziness / Continue as guest
  C->>S: POST /api/platform/token { deviceId, linkedToken? }
  S->>K: create-user (first time) / get-user-token
  K-->>S: { userId, token, isLinkedToAccount }
  S-->>C: { token, bazinessUserId, isLinked, switchToDeviceId? }
  C->>W: open login page (sdkType=js, linkedToken, allowGuest=false)
  W-->>C: postMessage { type: "login", data: "<token>" }
  C->>S: POST /api/platform/login { deviceId, token }
  S->>K: validate-user-token
  K-->>S: { isValid, userId }
  S-->>C: { ok } — or { ok, switchToDeviceId }
  C->>C: close sheet, or switch device id + reload

چیزهایی که سرور شما فراهم می‌کند

کلاینت شما هیچ‌وقت خودش API سرور پلتفرم را صدا نمی‌زند. سه endpoint از سرور خودتان را صدا می‌زند:

endpoint شما کلاینت می‌فرستد سرور شما پاسخ
GET /api/platform/config — کاری نمی‌کند، فقط تنظیمات را می‌گوید { enabled, webBase, apiBase, packageName, gameName, gameOfferUrl } یا { enabled: false }
POST /api/platform/token شناسه‌ی دستگاه، و linkedToken اگر هست اگر هنوز کاربر بازینس ندارد ← create-user؛ وگرنه ← get-user-token. اگر userId حاصل مال بازیکن دیگری باشد وصل نمی‌کند { token, userId, isLinkedToAccount } یا { switchToDeviceId }
POST /api/platform/login شناسه‌ی دستگاه، توکنی که صفحه‌ی ورود داده validate-user-token ← این حساب مال کیست؟ مال بازیکن دیگری ← جابه‌جایی؛ وگرنه ← وصل به همین بازیکن { ok: true } یا { ok: true, switchToDeviceId } یا { ok: false }

اگر پلتفرم در دسترس نباشد، /token پاسخ { enabled: true, error: "platform_unavailable" } را بدون توکن می‌دهد و بازی به هر حال بالا می‌آید.

اجرای اول: مهمان یا ورود

وقتی بازینس روشن است و بازیکن نه هنوز انتخابی کرده و نه وارد شده، اسپلش به جای نوار بارگذاری دو دکمه نشان می‌دهد:

  • «ورود با حساب بازینس»: صفحه‌ی ورود را باز می‌کند و منتظر می‌ماند.
  • «ادامه به عنوان مهمان»: انتخاب را به خاطر می‌سپارد و ادامه می‌دهد.

ترتیب بالا آمدن بازی:

  1. بازیکن را در سیستم حساب خود بازی وارد کنید.
  2. از سرور /api/platform/config را بپرسید. اگر بازینس خاموش بود، از همه‌ی قدم‌های بعد بگذرید.
  3. اگر انتخابی نشده و بازیکن وارد نیست، دو دکمه را نشان دهید و منتظر بمانید.
  4. /api/platform/token را صدا بزنید. اگر switchToDeviceId برگشت، آن شناسه را بپذیرید و از اول شروع کنید.
  5. اگر بازیکن همین الان وارد شد، پروفایلش را دوباره بخوانید تا صفحه حساب وارد شده را نشان دهد.

اگر بازیکن صفحه‌ی ورود را بدون وارد شدن بست، دو دکمه را دوباره نشان دهید.

جابه‌جایی حساب، قدم‌به‌قدم

جابه‌جایی وقتی پیش می‌آید که حساب بازینسی که بازیکن واردش می‌شود از قبل به بازیکن دیگری از بازیکنان شما وصل باشد، مثلاً بعد از نصب دوباره یا روی گوشی جدید:

  1. سرور پاسخ می‌دهد switchToDeviceId: "<شناسه‌ی دستگاه آن بازیکن>".
  2. کلاینت آن شناسه را به‌عنوان شناسه‌ی خودش ذخیره می‌کند و توکن بازینس ذخیره‌شده را فراموش می‌کند.
  3. بازی را از اول اجرا می‌کند، بدون linkedToken در آدرس.
  4. اجرای بعدی بازیکن دیگر را با همه‌ی پیشرفتش بالا می‌آورد.

باز شدن بازی از اپ بازینس

مهم

اپ بازینس حالا ?linkCode= (یک‌بارمصرف) را هم می‌فرستد. به‌جای linkedToken آن را به سرورتان بدهید. مهاجرت توکن را ببینید.

اپ بازینس بازی‌ها را با ?linkedToken=<توکن> در آدرس باز می‌کند:

  • آن توکن را به‌عنوان linkedToken به /api/platform/token بفرستید تا بازیکن در حساب درست بازینس قرار بگیرد. در اجرای بعدی آن را از آدرس پاک کنید.
  • اگر دستگاه از قبل توکن دیگری دارد، حساب بازینس دیگری دارد می‌آید: پیش از ادامه داده‌های محلی را پاک کنید و از اول شروع کنید.
  • ورود تعیین می‌کند کدام حساب بازی کند، هیچ‌وقت تعیین نمی‌کند بازیکن جدید است یا نه. بازیکن تازه‌واردی که این‌طور می‌آید هم باید آموزش و هدیه‌های خوش‌آمد شما را ببیند.

ورود در آینده، از تنظیمات

مهمان هر وقت بخواهد می‌تواند حسابش را وصل کند: تنظیمات ← باز کردن صفحه‌ی پروفایل (پارامترها). وقتی نما بسته شد، دوباره /api/platform/token را بپرسید و موجودی سکه را تازه کنید.

خروج

وقتی صفحه logout می‌فرستد، نما را ببندید، هر چه بازی به‌صورت محلی ذخیره کرده پاک کنید (هویت، توکن، انتخاب‌ها، پیشرفت آموزش) و مثل یک بازدیدکننده‌ی کاملاً جدید از اول شروع کنید.

تست ورود روی پلتفرم توسعه

پیشنهاد

روی پلتفرم توسعه (همان جایی که بازی‌های تستی به آن وصل‌اند) با هر شماره موبایلی و کد 111111 می‌توانید وارد شوید. هیچ پیامکی فرستاده نمی‌شود. با شماره‌های ساختگی هر چند حساب تستی که لازم دارید بسازید: یکی برای تست جابه‌جایی حساب، یکی برای یک مهمان تازه و غیره. محیط اصلی کد واقعی را پیامک می‌کند.

اشتباه‌های رایج

اشتباه نتیجه راه‌حل
پیام token صفحه ورود حساب شود فروشگاه جایزه به محض باز شدن بسته می‌شود؛ ورود اول پیش از وارد شدن بازیکن تمام می‌شود فقط login ورود است
وصل کردن توکن بدون validate-user-token هر کسی می‌تواند هر حسابی را مال خودش کند همیشه در سرور بررسی کنید
باز کردن صفحه‌ی ورود بدون توکن بازیکن ورود اول از اسپلش کاربر ناشناسی ندارد که به آن وصل شود مطمئن شوید /api/platform/token قبلاً اجرا شده و توکنش را به‌عنوان linkedToken بفرستید
نبودن allowGuest=false روی صفحه‌ی ورود بازیکن یک «ادامه به عنوان مهمان» دوم می‌بیند روی صفحه‌های ورود، پروفایل و پیشنهاد اضافه‌اش کنید
پاداش اتصال حساب بیش از یک بار خروج و ورود دوباره، پاداش را تکرار می‌کند هدیه‌ی «حساب وصل شد» را از سرور و یک بار برای هر بازیکن بدهید

کد آماده: endpointهای سرور، کنترلر و پیام‌های کلاینت.

راهنماها

گیتا کوین

بازیکن چطور گیتا کوین به دست می‌آورد و خرج می‌کند، بازی چطور موجودی را نشان می‌دهد، سکه را امن می‌دهد و با بازیکنانی که هنوز وارد نشده‌اند چه می‌کند.

گیتا کوین چطور کار می‌کند

گیتا کوین واحد پول بازینس است. بازیکن آن را در بازی شما به دست می‌آورد (برد مسابقه، رتبه‌ی تورنمنت، هدیه، خرید بسته) و در فروشگاه جایزه برای جایزه‌های واقعی خرج می‌کند. موجودی روی پلتفرم نگه داشته می‌شود، نه در دیتابیس شما:

کار چه کسی چطور
نمایش موجودی کلاینت شما user/get-coin با توکن کاربر بازیکن
دادن سکه سرور شما client/add-coin با توکن کلاینت
خرج کردن سکه بازیکن داخل صفحه‌ی فروشگاه جایزه؛ بازی شما کاری نمی‌کند

هشدار

هیچ‌وقت عددی را که به‌صورت محلی ذخیره شده موجودی حساب نکنید و هیچ‌وقت اجازه ندهید کلاینت سکه اضافه کند.

نمایش موجودی

موجودی را تازه کنید:

  • بلافاصله بعد از اینکه کلاینت توکنش را گرفت
  • وقتی یک صفحه‌ی بازینس بسته می‌شود، چون شاید بازیکن در فروشگاه جایزه سکه خرج کرده باشد
  • وقتی بازی دوباره به جلو می‌آید
  • بعد از اینکه سرور شما سکه داد

تا وقتی موجودی معلوم نیست یا درخواست شکست خورده، … نشان دهید یا آن را پنهان کنید. هیچ‌وقت 0 نشان ندهید؛ بازیکن خیال می‌کند سکه‌هایش از دست رفته.

دادن سکه به‌شکل امن

هر پرداخت یک transactionId یکتا لازم دارد که از ماهیت همان پرداخت ساخته شده، مثلاً match:17 یا reward_claim:42 (فرمول). در نتیجه:

  • تلاش دوباره، دو بار زدن دکمه یا درخواست تکراری هیچ‌وقت دو بار پرداخت نمی‌کند: پلتفرم تکرار را با 400 رد می‌کند
  • آن 400 را به معنی «قبلاً پرداخت شده» بگیرید و حساب‌داری خودتان را تمام کنید
  • هر پرداخت را در دفتر حساب خودتان هم ثبت کنید (به چه کسی، چقدر، چرا)، برای پشتیبانی و گزارش

بازیکنانی که هنوز وارد نشده‌اند

مهمان هم یک کاربر ناشناس بازینس دارد، پس می‌شود به او سکه داد. ولی خیلی از بازی‌ها مرز را وارد شدن می‌گذارند:

نوع پرداخت بازیکن وارد نشده چرا
دریافت‌ها: جایزه‌ای که بازیکن برای گرفتنش دکمه می‌زند (برد، جایزه‌ی تورنمنت، هدیه) با خطای «اول وارد شو» رد کنید؛ کلاینت صفحه‌ی ورود را باز می‌کند و بعد از ورود موفق دوباره تلاش می‌کند سکه‌ای که با آن جایزه‌ی واقعی می‌خرند باید مال یک حساب واقعی باشد
پرداخت‌هایی که نباید شکست بخورند: خرید پرداخت‌شده‌ای که گیتا کوین دارد سکه‌ها را در یک موجودی امانی محلی روی سرور نگه دارید سفارش پرداخت‌شده هیچ‌وقت نباید شکست بخورد
وقتی بازیکن وارد شد کل موجودی امانی را با یک add-coin (با transactionId مخصوص خودش) به بازینس منتقل کنید و بعد موجودی محلی را صفر کنید سکه‌ها دقیقاً یک بار منتقل می‌شوند

وقتی بازینس خاموش است هم جایزه‌های گیتا کوین را در همان موجودی محلی نگه دارید تا اقتصاد بازی بدون کلید هم قابل تست باشد.

ارز را صادقانه نشان دهید

  • همه جا آن را «گیتا کوین» بنامید و آیکون مخصوصش را بگذارید.
  • بیلدی که بدون بازینس منتشر می‌شود نباید جایزه‌ی گیتا کوین تبلیغ کند.

کد آماده: دادن سکه، نمایش موجودی.

راهنماها

صفحه‌ی جایزه («انتخاب جایزه»)

بگذارید بازیکن گیتا کوین‌هایش را با جایزه‌ی واقعی عوض کند: دکمه‌ای در بازی که فروشگاه جایزه‌ی بازینس را باز می‌کند.

این صفحه چیست

«انتخاب جایزه» فروشگاه جایزه‌ی بازینس است: صفحه‌ای که بازیکن در آن گیتا کوین را برای جایزه‌های واقعی خرج می‌کند. بازی شما سه کار می‌کند:

  1. برای بازیکن سکه به دست می‌آورد (گیتا کوین)
  2. موجودی را روی یک دکمه یا بنر نشان می‌دهد
  3. وقتی دکمه زده شد، صفحه‌ی جایزه را در وب‌ویوی داخل بازی باز می‌کند
flowchart LR
  A["بنر لابی<br/>«انتخاب جایزه» + موجودی"] -->|ضربه| B{"پلتفرم<br/>فعال است؟"}
  B -- خیر --> T["پیام: بعد از اتصال<br/>به بازینس فعال می‌شود"]
  B -- بله --> C["باز کردن شیت:<br/>{webBase}/shop?game_name=…"]
  C --> D["بازیکن جایزه انتخاب می‌کند<br/>و گیتا کوین خرج می‌کند"]
  D -->|کشیدن به پایین / بستن| E["شیت بسته شد ←<br/>get-coin دوباره"]

آدرس

Text
{web}/shop?game_name=<نام بازی>&packageName=<بسته>&sdkType=js&linkedToken=<توکن کاربر>&redirect=shop

پارامترها در صفحه‌های بازینس توضیح داده شده‌اند. دو قانون این‌جا مهم‌ترند:

  • /shop را باز کنید، نه ریشه‌ی سایت را؛ ریشه بازیکن داخل بازی را به صفحه‌ی ورود می‌برد.
  • allowGuest=false را اضافه نکنید؛ فروشگاه مقصد است، نه صفحه‌ی ورود.

دکمه در بازی شما

  • آن را جایی بگذارید که بازیکن می‌بیند: لابی یا منوی اصلی. موجودی زنده را با آیکون گیتا کوین روی آن نشان دهید.
  • وقتی بازینس خاموش است، یا پنهانش کنید یا با زدنش بگویید «بعد از اتصال به بازینس فعال می‌شود».
  • وسط آموزش، ضربه‌ی اول می‌تواند به جایش صفحه‌ی پیشنهاد بازی را باز کند.

بعد از بسته شدن صفحه

شاید بازیکن سکه خرج کرده باشد، پس به محض بسته شدن نما و دوباره وقتی بازی فوکوس می‌گیرد موجودی را تازه کنید.

پنهان کردن نوار پایین پلتفرم (اختیاری)

سایت بازینس نوار ناوبری پایین خودش را دارد (فروشگاه / بازی‌ها / خانه / ماموریت‌ها / بازینس من). داخل بازی شاید بخواهید پنهانش کنید. صفحه مال سایت دیگری است، پس CSS و اسکریپت شما به داخلش دسترسی ندارند. به جایش:

  1. وب‌ویو را به اندازه‌ی ارتفاع نوار بلندتر از ظرفش کنید.
  2. بگذارید ظرف بخش اضافه را برش بدهد.

نوار پایین صفحه است، پس بیرون محدوده‌ی دیده‌شده می‌افتد و بقیه‌ی صفحه مثل قبل اسکرول می‌شود. ارتفاع را روی گوشی‌های واقعی تنظیم کنید. کد.

بیلدهای بدون بازینس

بیلدی که بدون بازینس منتشر می‌شود (مثلاً بعضی بیلدهای فروشگاهی) نباید دکمه را نشان دهد و نباید جایزه‌ی گیتا کوین تبلیغ کند. بگذارید سرور تصمیم بگیرد: برای آن بیلد { enabled: false } برگرداند و کلاینت هیچ‌کدام را نکشد.

چک‌لیست

  • دکمه موجودی زنده را نشان می‌دهد (هنگام بارگذاری یا خطا …).
  • /shop را با game_name، packageName، sdkType=js، linkedToken و redirect=shop باز می‌کند.
  • نه allowGuest=false دارد و نه ریشه‌ی سایت را باز می‌کند.
  • موجودی بعد از بسته شدن نما و بعد از فوکوس دوباره‌ی بازی تازه می‌شود.
  • وقتی بازینس خاموش است، دکمه پنهان است یا توضیح می‌دهد چرا در دسترس نیست.
راهنماها

صفحه‌ی پیشنهاد آموزش (game-offer)

صفحه‌ای نمایشی که مخصوص بازی شما ساخته شده و یک بار وسط آموزش باز می‌شود تا به بازیکن تازه‌وارد نشان دهد با گیتا کوین چه چیزهایی می‌شود گرفت و یک بسته‌ی شروع رایگان به او پیشنهاد کند.

این صفحه چیست

هر بازی می‌تواند یک صفحه‌ی پیشنهاد روی بازینس داشته باشد که مخصوص همان بازی است و با game_name انتخاب می‌شود:

Text
{web}/game-offer?game_name=<نام بازی>

این صفحه جایزه‌هایی را که با گیتا کوین می‌شود گرفت نشان می‌دهد، به‌علاوه‌ی یک بسته‌ی رایگان مخصوص بازی شما؛ مثلاً صفحه‌ی بازی نمونه «بسته ویژه بازی example — رایگان» را با دکمه‌ی «خرید رایگان» نشان می‌دهد. این صفحه نمایشی است: یک بار و وسط آموزش بازش می‌کنید تا انتظار بازیکن شکل بگیرد. بعد از آموزش، دکمه‌ی جایزه‌ی شما فروشگاه جایزه‌ی واقعی را باز می‌کند.

نکته

از تیم پلتفرم بخواهید صفحه‌ی پیشنهاد بازی شما را راه بیندازد، و از همان game_name صفحه‌ی جایزه استفاده کنید.

آدرس

محیط آدرس
توسعه https://platform-web-dev.karizmastudios.org/game-offer?game_name=<نام بازی>
اصلی https://baziness.com/game-offer?game_name=<نام بازی>

مثل همه‌ی صفحه‌های بازینس، packageName و sdkType=js و همچنین linkedToken و allowGuest=false لازم دارد (پارامترها).

هشدار

بدون sdkType=js صفحه‌ی پیشنهاد هیچ‌وقت پیام close را نمی‌فرستد، پس دکمه‌اش نمی‌تواند نمای شما را ببندد.

جریان آموزش

flowchart TD
  A["درس آموزش تمام شد"] --> B["لابی: راهنما روی «انتخاب جایزه»<br/>(بقیه‌ی صفحه تیره)"]
  B -->|ضربه| C{"پلتفرم فعال است<br/>و شیت در دسترس؟"}
  C -- بله --> D["باز کردن game-offer در شیت<br/>مرحله = pack"]
  D -->|"دکمه‌ی بستن · کشیدن به پایین · زدن بیرون"| E["شیت بسته شد"]
  C -- خیر --> E
  E --> F["پنجره‌ی بسته‌ی شروع («خرید موفق»)"]
  F --> G["پایان آموزش ←<br/>از این به بعد بنر /shop را باز می‌کند"]

قانون‌ها

  1. پیش از باز کردن صفحه، قدم آموزش را ذخیره کنید. اگر بازیکن وقتی صفحه باز است بازی را دوباره بارگذاری کند، با بسته‌ی شروع ادامه می‌دهد و پیشنهاد را دو بار نمی‌بیند.
  2. به هر شکلی که صفحه بسته شد ادامه دهید: دکمه‌ی خودش، کشیدن به پایین یا زدن بیرون آن.
  3. آموزش را هیچ‌وقت معطل بازینس نکنید. اگر خاموش یا در دسترس نیست، مستقیم به بسته‌ی شروع بروید.
  4. بسته‌ی شروع را از سرور و یک بار برای هر بازیکن بدهید. آن را مثل یک خرید موفق («خرید موفق») نشان دهید، هرچند رایگان است.
  5. بازیکنانی که از اپ بازینس می‌آیند هم آموزش را می‌بینند. ورود، بازیکن را «قدیمی» نمی‌کند.
  6. بعد از آموزش /shop را باز کنید، دیگر هرگز صفحه‌ی پیشنهاد را.

تست کنید

  1. داده‌های سایت را پاک کنید (یا پنجره‌ی خصوصی) و بازی را باز کنید.
  2. در اسپلش یکی از دو گزینه را انتخاب کنید و درس اول آموزش را تمام کنید.
  3. فقط دکمه‌ی جایزه روشن است. آن را بزنید: صفحه‌ی پیشنهاد از پایین بالا می‌آید.
  4. آن را هر بار به یک روش ببندید (دکمه‌اش، کشیدن دستگیره، زدن بیرون). هر روش باید بسته‌ی شروع را بیاورد.
  5. دوباره دکمه‌ی جایزه را بزنید: حالا /shop واقعی باز می‌شود.

کد آماده: کلاینت. نسخه‌ی زنده را در بازی نمونه ببینید.

راهنماها

مهاجرت به کد اتصال و توکن‌های کوتاه‌عمر

پلتفرم توکن‌های بلندعمر کاربر را با توکن‌های ۱۵ دقیقه‌ای و کدهای اتصال یک‌بارمصرف جایگزین می‌کند. چه چیزی در سرور و کلاینت شما عوض می‌شود، قدم‌به‌قدم، و تا کی.

چه چیزی عوض می‌شود و چرا

تا الان توکن کاربر بازینس ۱۰ سال اعتبار داشت. توی آدرس‌ها هم جابه‌جا می‌شد:

  • بازی شما صفحه‌های بازینس را با ?linkedToken=<token> باز می‌کرد؛
  • اپ بازینس بازی شما را با ?linkedToken=<token> باز می‌کرد.

آدرس‌ها در لاگ سرورها، ابزارهای آنالیتیکس، تاریخچه‌ی مرورگر و هدر Referer ثبت می‌شوند. توکنی که از آن‌جا پیدا می‌شد، تا ده سال به سکه‌ها و حساب بازیکن دسترسی می‌داد و خروج از حساب هم جلویش را نمی‌گرفت.

مدل جدید:

قبل حالا
توکن دسترسی (چیزی که کلاینت به‌عنوان Bearer می‌فرستد) ۱۰ سال اعتبار ۱۵ دقیقه اعتبار
توکن تمدید (refresh token) — توکن دسترسی را تمدید می‌کند؛ روی سرور شما نگه داشته می‌شود، هیچ‌وقت روی دستگاه؛ یک‌بارمصرف است و هر تمدید یک توکن تازه برمی‌گرداند
جابه‌جا کردن بازیکن بین اپ‌ها ?linkedToken=<token> ?linkCode=<code>: یک کد یک‌بارمصرف با حدود ۲ دقیقه اعتبار که بعد از اولین استفاده بی‌اثر است

مهم

در دوره‌ی مهاجرت هر دو مدل کنار هم کار می‌کنند. endpointهای قدیمی و ?linkedToken= کار می‌کنند و اپ بازینس هم ?linkCode= و هم ?linkedToken= را برای بازی‌ها می‌فرستد. تیم پلتفرم یک تاریخ پایان اعلام می‌کند: بعد از آن، توکن‌های قدیمی و linkedToken دیگر کار نمی‌کنند. مهاجرت را قبل از آن تاریخ انجام دهید.

جریان بعد از مهاجرت:

sequenceDiagram
  autonumber
  participant C as کلاینت بازی
  participant S as سرور بازی
  participant K as API پلتفرم
  participant W as صفحه‌ی بازینس (شیت)
  C->>S: POST /api/platform/token { deviceId, linkCode? }
  alt بار اول، یا ورود با ?linkCode=
    S->>K: POST v2/client/create-user { deviceId, linkCode }
  else بازیکن آشنا
    S->>K: POST auth/refresh { refreshToken ذخیره‌شده }
  end
  K-->>S: { userId, isLinkedToAccount, accessToken (۱۵ دقیقه), refreshToken }
  Note over S: refreshToken جدید را روی بازیکن ذخیره کن
  S-->>C: { token: accessToken, tokenExpiresAt, bazinessUserId, isLinked }
  C->>S: POST /api/platform/link-code (قبل از باز کردن صفحه)
  S->>K: POST v2/client/link-code/{userId}
  K-->>S: { code } (یک‌بارمصرف، حدود ۲ دقیقه)
  S-->>C: { linkCode }
  C->>W: باز کردن صفحه (sdkType=js, linkCode, …)
  W-->>C: postMessage { type: "login", data: "<token>" }
  C->>S: POST /api/platform/login { deviceId, token }
  S->>K: POST client/validate-user-token { token }
  S-->>C: { ok } — یا { ok, switchToDeviceId }

تغییرات در یک نگاه

امروز بعد از مهاجرت کجا
client/create-user با linkedToken v2/client/create-user با linkCode؛ tokens.refreshToken را ذخیره کنید سرور
client/get-user-token/{id} در هر بار اجرا auth/refresh با توکن تمدید ذخیره‌شده؛ v2/client/get-user-token/{id} فقط وقتی نشستی (session) نیست سرور
باز کردن صفحه‌ها با linkedToken=<token> باز کردن صفحه‌ها با linkCode=<code>؛ برای هر صفحه یک کد تازه از v2/client/link-code/{id} سرور + کلاینت
اپ بازینس بازی شما را با ?linkedToken= باز می‌کند ?linkCode=: مثل توکن قبلی آن را به سرورتان بدهید کلاینت
GET client/validate-user-token/{token} POST client/validate-user-token با { token } (توکن دیگر در آدرس نیست) سرور
کلاینت توکنش را سال‌ها نگه می‌دارد کلاینت فقط توکن دسترسی را نگه می‌دارد؛ وقتی منقضی شد دوباره از سرور خودتان می‌گیرد کلاینت

باقی چیزها عوض نمی‌شوند: پیام‌های صفحه (login، token، close، logout)، سکه‌ها، جایزه‌ها و پرداخت مثل قبل کار می‌کنند.

سرور، قدم‌به‌قدم

۱. برای هر بازیکن یک توکن تمدید نگه دارید

یک ستون به رکورد بازیکن اضافه کنید، مثلاً baziness_refresh_token. با آن مثل رمز عبور رفتار کنید:

  • هیچ‌وقت لاگش نکنید؛
  • هیچ‌وقت به کلاینت نفرستید؛
  • اگر رازهای دیگر را رمزنگاری می‌کنید، این را هم رمزنگاری کنید.

همیشه با جدیدترین توکن جایگزینش کنید. هر create-user، get-user-token و refresh یک توکن تمدید تازه برمی‌گرداند و توکن قبلی دیگر کار نمی‌کند.

۲. /api/platform/token: به‌جای get-user-token تمدید کنید

این endpointی است که کلاینت در شروع اجرا صدا می‌زند. کلاینت حالا به‌جای linkedToken، linkCode می‌فرستد.

  1. کلاینت linkCode فرستاده، یا بازیکن هنوز کاربر بازینس ندارد: v2/client/create-user را با deviceId و linkCode صدا بزنید. بعد userId برگشتی را مثل امروز بررسی کنید:
    • userId مال بازیکن دیگری از بازیکنان شماست ← switchToDeviceId برگردانید (همان آدم روی دستگاه دیگر).
    • بازیکن از قبل userId دیگری دارد ← یک حساب بازینس دیگر رسیده است. آن را به‌عنوان بازیکن جدا شروع کنید و switchToDeviceId برگردانید (همان قاعده‌ی قدیمی «داده‌های محلی را پاک کن و از نو شروع کن»).
    • در غیر این صورت ← userId و توکن تمدید را روی همین بازیکن ذخیره کنید.
    • اگر کد منقضی یا مصرف‌شده بود (400 invalid_link_code)، طوری ادامه دهید که انگار کدی نیامده.
  2. بازیکن آشنا: auth/refresh را با توکن تمدید ذخیره‌شده صدا بزنید و توکن تازه را ذخیره کنید. جواب isLinkedToAccount را هم دارد، پس همچنان می‌فهمید بازیکن کی وارد حسابش شده.
    • 401 refresh_token_rotated: درخواست دیگری از همین بازیکن لحظه‌ای قبل تمدید کرده. توکن تمدید را دوباره از دیتابیس بخوانید و یک بار دیگر امتحان کنید.
    • هر 401 دیگر (session_revoked، refresh_token_expired، invalid_refresh_token، refresh_token_reused): نشست تمام شده. با v2/client/get-user-token/{userId} نشست تازه بگیرید و توکن تمدیدش را ذخیره کنید.
    • 403 account_suspended: حساب مسدود است.
  3. به کلاینت توکن دسترسی و زمان انقضایش را بدهید: { token, tokenExpiresAt, bazinessUserId, isLinkedToAccount }. توکن تمدید را هرگز نفرستید.

پیشنهاد

بازیکن‌هایی که از قبل دارید هنوز توکن تمدید ندارند. قدم ۲ خودش آن‌ها را درست می‌کند: توکن تمدید ذخیره‌شده‌ای نیست ← یک بار v2/client/get-user-token، و از آن به بعد auth/refresh.

وقتی کلاینت می‌خواهد یکی از صفحه‌های بازینس را باز کند، از سرور شما کد می‌خواهد. سرور شما v2/client/link-code/{userId} را صدا می‌زند و { linkCode } را برمی‌گرداند.

  • هر کد یک بار و حدود دو دقیقه کار می‌کند: برای هر صفحه‌ای که باز می‌کنید کد تازه بگیرید.
  • فقط صفحه‌های بازینس می‌توانند از آن استفاده کنند. حتی اگر لو برود، نمی‌شود با آن وارد بازی شما شد.
  • این endpoint را مثل بقیه‌ی endpointهای بازیکن محافظت کنید: فقط خود بازیکن می‌تواند کد خودش را بگیرد.

۴. /api/platform/login: بررسی با POST

منطق عوض نمی‌شود. فقط نوع درخواست عوض می‌شود: POST client/validate-user-token با { token } در بدنه.

توکنی که در پیام login می‌رسد حالا ۱۵ دقیقه‌ای است. همان لحظه بررسی‌اش کنید، همان‌طور که امروز هم می‌کنید.

اگر توکن مال کاربر بازینسی غیر از کاربر فعلی بازیکن بود، توکن تمدید ذخیره‌شده‌ی بازیکن را پاک کنید؛ فراخوانی بعدی /token نشست تازه شروع می‌کند.

۵. endpoint جدید /api/platform/logout

وقتی صفحه logout می‌فرستد، کلاینت قبل از شروع دوباره این را صدا می‌زند. سرور شما نشست را می‌بندد (auth/logout) و توکن تمدید ذخیره‌شده را پاک می‌کند.

کد سرور

اضافه‌ها به کلاینت پلتفرم شما (کنار createUser / getUserAccessToken):

// platform/baziness.ts — ADD these next to createUser / getUserAccessToken (they reuse API_BASE and platformFetch).

/** A player's session: the access token goes to your client, the refresh token stays in your database. */
export interface PlatformSession {
  userId: number;
  isLinkedToAccount: boolean;
  accessToken: string;
  accessTokenExpiresAt: string;
  refreshToken: string;
}

const readSession = (j: Record<string, any>): PlatformSession => {
  const t = j.tokens ?? j; // v2 create-user / get-user-token nest the pair in `tokens`; auth/refresh returns it flat
  return {
    userId: Number(j.userId),
    isLinkedToAccount: Boolean(j.isLinkedToAccount),
    accessToken: String(t.accessToken),
    accessTokenExpiresAt: String(t.accessTokenExpiresAt),
    refreshToken: String(t.refreshToken),
  };
};

/** First time, or when the client brought a ?linkCode=. Throws (status 400) for an expired or used code. */
export const createUserSession = async (deviceId: string, platform: string, store: string, linkCode: string | null) =>
  readSession(await platformFetch("POST", "/v2/client/create-user", { deviceId, platform, store, linkCode }));

/** A new session for a player you know — only when you have no refresh token or the session ended. */
export const startUserSession = async (bazinessUserId: number) =>
  readSession(await platformFetch("POST", `/v2/client/get-user-token/${bazinessUserId}`));

/**
 * Renews a session. Returns the new session, "rotated" when a parallel request refreshed first
 * (reload the stored token and retry once), or null when the session is over (start a new one).
 */
export async function refreshUserSession(refreshToken: string): Promise<PlatformSession | "rotated" | null> {
  const res = await fetch(`${API_BASE}/auth/refresh`, {   // no client token: the refresh token is the credential
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  });
  if (res.ok) return readSession(await res.json());
  const error = ((await res.json().catch(() => ({}))) as { error?: string }).error;
  if (res.status === 401 && error === "refresh_token_rotated") return "rotated";
  if (res.status === 403) throw Object.assign(new Error("account_suspended"), { status: 403 });
  if (res.status === 401) return null;
  throw new Error(`auth/refresh failed (${res.status})`); // 5xx / network: keep the old session, try later
}

/** Ends a session (player signed out). Never throws. */
export async function endUserSession(refreshToken: string): Promise<void> {
  await fetch(`${API_BASE}/auth/logout`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  }).catch(() => {});
}

/** One-time code for opening ONE Baziness page as this player (~2 minutes). */
export const createPageLinkCode = async (bazinessUserId: number) =>
  String((await platformFetch("POST", `/v2/client/link-code/${bazinessUserId}`)).code);

/** REPLACES the old GET version: the token now goes in the body, not in the URL. */
export async function validateUserAccessToken(token: string): Promise<number> {
  const j = await platformFetch("POST", "/client/validate-user-token", { token });
  if (!(j.isValid ?? j.IsValid)) throw new Error("invalid user token");
  return Number(j.userId ?? j.UserId ?? 0);
}

چهار endpoint:

import {
  createUserSession, startUserSession, refreshUserSession, endUserSession, createPageLinkCode,
  validateUserAccessToken, platformClientConfig, platformEnabled, type PlatformSession,
} from "./platform/baziness";
import { db } from "./db"; // your own data layer. New column on the player: baziness_refresh_token (a secret)

// What the client gets. The refresh token never leaves your server.
const forClient = (s: PlatformSession) => ({
  token: s.accessToken, tokenExpiresAt: s.accessTokenExpiresAt,
  bazinessUserId: s.userId, isLinkedToAccount: s.isLinkedToAccount,
});

async function keep(playerId: number, s: PlatformSession) {
  await db.setRefreshToken(playerId, s.refreshToken); // always the NEWEST one: the old one stops working
  return s;
}

/** A valid session for a player who already has a Baziness user. */
async function sessionFor(player: { id: number; bazinessUserId: number; bazinessRefreshToken: string | null }) {
  if (player.bazinessRefreshToken) {
    let r = await refreshUserSession(player.bazinessRefreshToken);
    if (r === "rotated") {                       // another request of this player refreshed a moment ago
      const latest = await db.getRefreshToken(player.id);
      r = latest ? await refreshUserSession(latest) : null;
      if (r === "rotated") r = null;
    }
    if (r) return keep(player.id, r);
  }
  // No refresh token yet (players from before the migration) or the session ended.
  return keep(player.id, await startUserSession(player.bazinessUserId));
}

app.post("/api/platform/token", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const linkCode: string | null = req.body.linkCode ?? null;           // replaces linkedToken
  if (!deviceId) return res.status(400).json({ error: "deviceId required" });
  if (!platformEnabled()) return res.json({ enabled: false });

  const config = { enabled: true, ...platformClientConfig() };
  const me = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
  try {
    if (linkCode || !me.bazinessUserId) {
      // First time, or arrived from the Baziness app with ?linkCode=: which Baziness user is this?
      const s = await createUserSession(deviceId, "Web", "Direct", linkCode)
        .catch((e) => (linkCode && e.status === 400 ? null : Promise.reject(e))); // expired or used code
      if (s) {
        const owner = await db.findUserByBazinessId(s.userId);
        if (owner && owner.id !== me.id) {                  // the same person, another player of yours
          await keep(owner.id, s);
          return res.json({ ...config, switchToDeviceId: owner.deviceId });
        }
        if (me.bazinessUserId && me.bazinessUserId !== s.userId) {
          // A different Baziness account arrived on a device that already has a player:
          // it becomes its own player (the old "clear local data and start again" rule).
          const fresh = await db.createPlayerForBaziness(s.userId, s.isLinkedToAccount);
          await keep(fresh.id, s);
          return res.json({ ...config, switchToDeviceId: fresh.deviceId });
        }
        await db.linkBaziness(me.id, s.userId, s.isLinkedToAccount);
        await keep(me.id, s);
        return res.json({ ...config, ...forClient(s) });
      }
      if (!me.bazinessUserId) {                              // stale code and no Baziness user yet
        const s2 = await createUserSession(deviceId, "Web", "Direct", null);
        await db.linkBaziness(me.id, s2.userId, s2.isLinkedToAccount);
        await keep(me.id, s2);
        return res.json({ ...config, ...forClient(s2) });
      }
    }
    const s = await sessionFor(me);
    if (s.isLinkedToAccount !== me.isBazinessLinked) await db.setLinked(me.id, s.isLinkedToAccount);
    return res.json({ ...config, ...forClient(s) });
  } catch (err) {
    console.error("[platform] token failed:", (err as Error).message);
    const suspended = (err as { status?: number }).status === 403;
    return res.json({ ...config, error: suspended ? "account_suspended" : "platform_unavailable" }); // never block the boot
  }
});

// Protect it like your other player endpoints: only the player themself may ask for their code.
app.post("/api/platform/link-code", async (req, res) => {
  const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
  if (!me?.bazinessUserId) return res.status(409).json({ error: "call /api/platform/token first" });
  try {
    return res.json({ linkCode: await createPageLinkCode(me.bazinessUserId) });
  } catch {
    return res.status(503).json({ error: "platform_unavailable" });
  }
});

app.post("/api/platform/login", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const token = String(req.body.token ?? "").trim();
  if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
  if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
  try {
    const bazinessUserId = await validateUserAccessToken(token);         // POST now; verify right away (15-min token)
    const owner = await db.findUserByBazinessId(bazinessUserId);
    const me = await db.ensureUserByDevice(deviceId);
    if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
    if (me.bazinessUserId !== bazinessUserId) await db.setRefreshToken(me.id, null); // belonged to the old user
    await db.linkBaziness(me.id, bazinessUserId, true);
    return res.json({ ok: true });                                       // the client then calls /token again
  } catch {
    return res.json({ ok: false, reason: "invalid" });
  }
});

// The page sent `logout`: end the session before the client starts over as a new visitor.
app.post("/api/platform/logout", async (req, res) => {
  const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
  if (me?.bazinessRefreshToken) {
    await endUserSession(me.bazinessRefreshToken);
    await db.setRefreshToken(me.id, null);
  }
  return res.json({ ok: true });
});

کلاینت، قدم‌به‌قدم

  1. در شروع اجرا، به‌جای linkedToken، linkCode را (از ?linkCode= در آدرس یا از deep link) به /api/platform/token بفرستید. بعد از آن linkCode و linkedToken را از آدرس حذف کنید: هر کد یک بار کار می‌کند.
  2. توکن را برای اجراهای بعدی ذخیره نکنید (localStorage، PlayerPrefs). سرور شما نشست را نگه می‌دارد و با هر /api/platform/token یک توکن تازه می‌دهد.
  3. قبل از استفاده از توکن (user/get-coin) tokenExpiresAt را نگاه کنید. اگر تا یک دقیقه‌ی دیگر منقضی می‌شود، یا درخواستی 401 گرفت، دوباره /api/platform/token را صدا بزنید.
  4. برای باز کردن صفحه‌ی بازینس، از /api/platform/link-code کد بگیرید و به‌جای linkedToken، linkCode=<code> را در آدرس صفحه بگذارید. بقیه‌ی پارامترها همان قبلی‌ها هستند (صفحه‌ها).
  5. با پیام logout صفحه، /api/platform/logout را صدا بزنید و بعد مثل قبل از نو شروع کنید.
// PlatformController — the parts that change.

// 1. Boot: forward the one-time ?linkCode= (it replaces ?linkedToken=). Don't keep the token in
//    localStorage any more: your server keeps the session and gives you a fresh token on demand.
async init(): Promise<boolean> {
  const params = new URLSearchParams(location.search);
  const linkCode = params.get("linkCode");
  const res = await fetch("/api/platform/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId, linkCode }),
  });
  const data = await res.json();
  if (data.switchToDeviceId) return this.switchTo(data.switchToDeviceId); // unchanged
  this.token = data.token ?? null;
  this.tokenExpiresAt = data.tokenExpiresAt ? Date.parse(data.tokenExpiresAt) : 0;
  if (linkCode || params.has("linkedToken")) {                              // a code works once
    params.delete("linkCode"); params.delete("linkedToken");
    const rest = params.toString();
    history.replaceState(null, "", rest ? `${location.pathname}?${rest}` : location.pathname);
  }
  return !!this.token;
}

// 2. The token lives 15 minutes: ask your server again shortly before it expires, or after a 401.
async accessToken(): Promise<string | null> {
  if (!this.token || this.tokenExpiresAt - Date.now() < 60_000) await this.init();
  return this.token;
}

async getCoin(): Promise<number> {
  const call = async () => fetch(`${this.apiBase}/user/get-coin`, {
    headers: { Authorization: `Bearer ${await this.accessToken()}` },
  });
  let res = await call();
  if (res.status === 401) { this.token = null; res = await call(); }        // renew once, then give up
  return res.ok ? Number((await res.json()).amount ?? 0) : 0;
}

// 3. Pages: a fresh one-time code for every page you open, instead of the token.
async pageUrl(path: string, o: PageOptions = {}): Promise<string> {
  const res = await fetch("/api/platform/link-code", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId }),
  });
  const { linkCode } = await res.json();
  const url = new URL(bazinessPageUrl(this.webBase, path, this.packageName, null, o)); // no token any more
  url.searchParams.set("linkCode", linkCode);
  return url.toString();
}

// 4. The page sent `logout`: tell your server first, then start over as before.
async onPageLogout() {
  await fetch("/api/platform/logout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId }),
  }).catch(() => {});
  this.resetAndRestart(); // unchanged: delete local data, new visitor
}

endpointهای جدید پلتفرم

همه زیر همان {api} همیشگی هستند. endpointهای client مثل قبل توکن کلاینت لازم دارند.

ساخت کاربر همراه با نشست

HTTP
POST {api}/v2/client/create-user
Authorization: Bearer <client token>
Content-Type: application/json

{ "deviceId": "8f1c2a…", "platform": "Android", "store": "CafeBazaar", "linkCode": null }

→ 200 OK
{
  "userId": 239236,
  "token": "eyJhbGciOiJIUzI1NiIs…",              // the access token (same as tokens.accessToken)
  "isLinkedToAccount": false,
  "tokens": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs…",       // 15 minutes — the client uses this
    "accessTokenExpiresAt": "2026-10-22T10:15:00+00:00",
    "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…", // keep on YOUR server only
    "refreshTokenExpiresAt": "2026-11-21T10:00:00+00:00"
  }
}

→ 400 { "success": false, "error": "invalid_link_code" }   // the code expired or was already used

تمدید نشست

توکن کلاینت لازم نیست: خود توکن تمدید مدرک است. این را از سرور خودتان صدا بزنید.

HTTP
POST {api}/auth/refresh
Content-Type: application/json

{ "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…" }

→ 200 OK
{
  "userId": 239236,
  "isLinkedToAccount": true,
  "accessToken": "eyJhbGciOiJIUzI1NiIs…",
  "accessTokenExpiresAt": "2026-10-22T10:30:00+00:00",
  "refreshToken": "rt1.Wn0QhZ…",                  // store THIS one — the old one no longer works
  "refreshTokenExpiresAt": "2026-11-21T10:15:00+00:00"
}

→ 401 { "success": false, "error": "refresh_token_rotated" }  // another request refreshed first: reload yours, retry once
→ 401 { "success": false, "error": "session_revoked" }        // also invalid_refresh_token, refresh_token_expired,
                                                               // refresh_token_reused: get a new session
→ 403 { "success": false, "error": "account_suspended" }

نشست تازه برای بازیکن آشنا

HTTP
POST {api}/v2/client/get-user-token/239236
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true,
  "tokens": { "accessToken": "…", "accessTokenExpiresAt": "…", "refreshToken": "rt1.…", "refreshTokenExpiresAt": "…" } }

→ 403 { "success": false, "error": "access denied" }   // the user belongs to another game
HTTP
POST {api}/v2/client/link-code/239236
Authorization: Bearer <client token>

→ 200 OK
{ "code": "q3Jc9yX2tL0…", "expiresAt": "2026-10-22T10:02:00+00:00" }   // one page, once, ~2 minutes

→ 403 { "success": false, "error": "access denied" }   // the user belongs to another game

بررسی توکن (در بدنه به‌جای آدرس)

HTTP
POST {api}/client/validate-user-token
Authorization: Bearer <client token>
Content-Type: application/json

{ "token": "eyJhbGciOiJIUzI1NiIs…" }

→ 200 OK
{ "userId": 239236, "isValid": true }

→ 200 OK   (invalid or expired: not an HTTP error)
{ "userId": 0, "isValid": false }

بستن نشست

POST {api}/auth/logout با { "refreshToken": "rt1.…" }. همیشه 200 جواب می‌دهد.

خطاها

پاسخ کجا یعنی چه کنید
401 refresh_token_rotated auth/refresh درخواست دیگری از همین بازیکن زودتر تمدید کرده توکن تمدید را دوباره بخوانید و یک بار دیگر امتحان کنید
401 session_revoked، refresh_token_expired، invalid_refresh_token، refresh_token_reused auth/refresh نشست تمام شده v2/client/get-user-token/{id} و ذخیره‌ی توکن تمدید تازه
403 account_suspended auth/refresh حساب مسدود است پیام مسدودی را نشان دهید
400 invalid_link_code v2/client/create-user کد منقضی یا مصرف شده، یا برای بازی شما نبوده بدون کد ادامه دهید
403 v2/client/link-code/{id}، v2/client/get-user-token/{id} کاربر مال بازی دیگری است userId ذخیره‌شده را بررسی کنید
401 user/get-coin (کلاینت) توکن ۱۵ دقیقه‌ای منقضی شده دوباره /api/platform/token و یک بار تکرار

چک‌لیست

روی پلتفرم توسعه تست کنید (هر شماره‌ای، کد 111111):

  • بازیکن جدید بازی را باز می‌کند: کلاینت توکن می‌گیرد و دیتابیس شما برایش توکن تمدید دارد.
  • اجرای دوباره: auth/refresh استفاده می‌شود (نه get-user-token) و توکن تمدید ذخیره‌شده عوض شده.
  • دو اجرای هم‌زمان یک بازیکن: هر دو موفق‌اند (یکی با تکرار بعد از rotated).
  • بازیکنی از قبل از مهاجرت یک بار بازی را باز می‌کند و توکن تمدید می‌گیرد.
  • همه‌ی صفحه‌های بازینس با linkCode=… و بدون linkedToken باز می‌شوند؛ صفحه‌ی ورود، پروفایل، فروشگاه جایزه و صفحه‌ی پیشنهاد همه کار می‌کنند.
  • ورود در صفحه با شماره‌ای که مال بازیکن دیگری از بازیکنان شماست، همچنان به آن بازیکن جابه‌جا می‌شود.
  • باز کردن بازی از اپ بازینس با ?linkCode= به حساب درست می‌رسد؛ همان لینک را دو بار باز کنید ← بار دوم کد نادیده گرفته می‌شود و بازی باز هم بالا می‌آید.
  • بعد از ۱۵ دقیقه بازی، موجودی سکه هنوز بارگذاری می‌شود (توکن از طریق سرور شما تمدید شده).
  • logout از صفحه نشست را می‌بندد؛ اجرای بعدی یک بازدیدکننده‌ی جدید است.
  • در لاگ‌های شما توکن تمدید نیست و هیچ توکنی در آدرس‌ها نیست.

نکته

تا تاریخ پایان، اپ بازینس ?linkedToken= را هم می‌فرستد. وقتی بازی شما linkCode را می‌خواند، linkedToken را نادیده بگیرید. بعد از تاریخ پایان دیگر فرستاده نمی‌شود و توکن‌های قدیمی هم کار نمی‌کنند.

نمونه کد

کد سرور

کد آماده‌ی سرور برای همه‌ی قدم‌ها (کلاینت پلتفرم، endpointهای خودتان، پرداخت گیتا کوین، پرداخت بازینس و برچسب‌های کمپین) به TypeScript، C# و Python.

این نمونه‌ها قدم‌های API و راهنماها را پیاده می‌کنند. هر کدام به سه زبان TypeScript (Node)، C# (.NET) و Python آمده است؛ یک زبانه را انتخاب کنید و انتخابتان در همه‌ی صفحه‌ها به خاطر سپرده می‌شود. از Express، ASP.NET minimal API و FastAPI استفاده شده، ولی منطق در هر فریم‌ورکی همین است. هر جا db… صدا زده شده، دیتابیس خودتان را بگذارید.

تنظیمات

.env (server)
# --- Baziness platform: accounts, Gita coins, reward pages -------------------------------
BAZINESS_PLATFORM_ENABLED=false          # master switch: nothing shows until "true"
BAZINESS_PLATFORM_USE_PRODUCTION=false   # selects the default API + web URLs below
BAZINESS_CLIENT_ID=
BAZINESS_CLIENT_SECRET=
BAZINESS_PLATFORM_PACKAGE_NAME=com.yourstudio.yourgame
# Optional overrides (derived from USE_PRODUCTION when blank):
#   dev  api https://platform-dev.karizmastudios.org/api   web https://platform-web-dev.karizmastudios.org
#   prod api https://platform.karizmastudio.com/api        web https://baziness.com
BAZINESS_PLATFORM_API_URL=
BAZINESS_PLATFORM_WEB_URL=
BAZINESS_GAME_NAME=yourgame              # ?game_name= of your pages on the platform (rewards, game offer)
# Optional: the tutorial offer page (blank = {web}/game-offer?game_name=<BAZINESS_GAME_NAME>)
BAZINESS_GAME_OFFER_URL=

# --- Baziness Pay: real-money purchases ----------------------------------------------------
BAZINESS_PAY_ENABLED=false              # buy buttons stay disabled until "true"
#   dev  https://pay-dev.karizmastudios.org   prod  https://pay.baziness.com
BAZINESS_PAY_BASE_URL=https://pay-dev.karizmastudios.org
BAZINESS_PAY_PACKAGE_NAME=com.yourstudio.yourgame   # the Pay app's package (pname) — may differ from BAZINESS_PLATFORM_PACKAGE_NAME
BAZINESS_SKU_PREFIX=                    # optional prefix: SKU = prefix + product id
DEV_FAKE_BAZINESS_PAY=false             # local development only (see the Pay page)

کلاینت پلتفرم

یک کلاس کوچک که قدم‌های ۱ تا ۵ را انجام می‌دهد: با اولین استفاده توکن کلاینت را می‌گیرد، بعد از 401 دوباره می‌گیرد و شناسه‌ی پرداخت را از کلید پرداخت می‌سازد.

// platform/baziness.ts — all credentials stay on the server.
import { createHash } from "node:crypto";

const DEV_API = "https://platform-dev.karizmastudios.org/api";
const PROD_API = "https://platform.karizmastudio.com/api";
const DEV_WEB = "https://platform-web-dev.karizmastudios.org";
const PROD_WEB = "https://baziness.com";

const env = process.env;
const ENABLED = env.BAZINESS_PLATFORM_ENABLED === "true";
const USE_PROD = env.BAZINESS_PLATFORM_USE_PRODUCTION === "true";
const CLIENT_ID = env.BAZINESS_CLIENT_ID ?? "";
const CLIENT_SECRET = env.BAZINESS_CLIENT_SECRET ?? "";
const PACKAGE_NAME = env.BAZINESS_PLATFORM_PACKAGE_NAME ?? "";
const trim = (u: string) => u.replace(/\/+$/, "");
export const API_BASE = trim(env.BAZINESS_PLATFORM_API_URL || (USE_PROD ? PROD_API : DEV_API));
export const WEB_BASE = trim(env.BAZINESS_PLATFORM_WEB_URL || (USE_PROD ? PROD_WEB : DEV_WEB));

export const platformEnabled = () => ENABLED && CLIENT_ID !== "" && CLIENT_SECRET !== "";

/** Public, non-secret config for the browser. */
export const platformClientConfig = () => ({ webBase: WEB_BASE, apiBase: API_BASE, packageName: PACKAGE_NAME });

export interface PlatformUser { userId: number; token: string; isLinkedToAccount: boolean }

let bearer: string | null = null;

async function authenticate(): Promise<void> {
  const res = await fetch(`${API_BASE}/client/auth`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`client/auth failed (${res.status})`);
  const json = (await res.json()) as Record<string, unknown>;
  bearer = String(json.token ?? json.Token ?? "");
  if (!bearer) throw new Error("client/auth returned no token");
}

async function platformFetch(method: "GET" | "POST", path: string, body?: unknown) {
  if (!bearer) await authenticate();
  const send = () =>
    fetch(`${API_BASE}${path}`, {
      method,
      headers: { Authorization: `Bearer ${bearer}`, ...(body ? { "Content-Type": "application/json" } : {}) },
      body: body ? JSON.stringify(body) : undefined,
    });
  let res = await send();
  if (res.status === 401) { await authenticate(); res = await send(); } // bearer expired
  if (!res.ok) throw Object.assign(new Error(`${path} failed (${res.status})`), { status: res.status }); // 400 from add-coin = already paid
  return (await res.json()) as Record<string, unknown>;
}

const readUser = (j: Record<string, unknown>): PlatformUser => ({
  userId: Number(j.userId ?? j.UserId ?? 0),
  token: String(j.token ?? j.Token ?? ""),
  isLinkedToAccount: Boolean(j.isLinkedToAccount ?? j.IsLinkedToAccount ?? false),
});

export const createUser = async (deviceId: string, platform: string, store: string, linkedToken: string | null) =>
  readUser(await platformFetch("POST", "/client/create-user", { deviceId, platform, store, linkedToken }));

export const getUserAccessToken = async (bazinessUserId: number) =>
  readUser(await platformFetch("GET", `/client/get-user-token/${bazinessUserId}`));

export async function validateUserAccessToken(token: string): Promise<number> {
  const j = await platformFetch("GET", `/client/validate-user-token/${encodeURIComponent(token)}`);
  if (!(j.isValid ?? j.IsValid)) throw new Error("invalid user token");
  return Number(j.userId ?? j.UserId ?? 0);
}

/** Credit Gita coins. `grantKey` makes the transaction id deterministic: a repeat answers 400 (= already paid). */
export async function addCoin(bazinessUserId: number, amount: number, grantKey: string, metaData?: object) {
  const hash = createHash("sha1").update(grantKey).digest("hex").slice(0, 24);
  const j = await platformFetch("POST", "/client/add-coin", {
    userId: bazinessUserId, amount, transactionId: `${bazinessUserId}_${amount}_${hash}`, metaData: metaData ?? null,
  });
  return Number(j.amount ?? j.Amount ?? 0); // the player's new balance
}

گفتن تنظیمات به کلاینت

GET /api/platform/config: بازینس روشن است یا نه، و صفحه‌ها و API کجا هستند. اثر جانبی ندارد و هرگز کلید محرمانه را برنمی‌گرداند.

import express from "express";
import { platformClientConfig, platformEnabled, WEB_BASE } from "./platform/baziness";

const GAME_NAME = process.env.BAZINESS_GAME_NAME ?? "example"; // your game's name on the platform
const GAME_OFFER_URL = process.env.BAZINESS_GAME_OFFER_URL || `${WEB_BASE}/game-offer?game_name=${encodeURIComponent(GAME_NAME)}`;

const app = express();
app.use(express.json());

// Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret.
app.get("/api/platform/config", (_req, res) => {
  res.json(platformEnabled()
    ? { enabled: true, ...platformClientConfig(), gameName: GAME_NAME, gameOfferUrl: GAME_OFFER_URL }
    : { enabled: false });
});

endpointهای ورود

POST /api/platform/token و POST /api/platform/login، همان‌طور که در ورود ← چیزهایی که سرور فراهم می‌کند آمده.

import { createUser, getUserAccessToken, platformClientConfig, platformEnabled, validateUserAccessToken } from "./platform/baziness";
import { db } from "./db"; // your own data layer

app.post("/api/platform/token", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const linkedToken: string | null = req.body.linkedToken ?? null;
  if (!deviceId) return res.status(400).json({ error: "deviceId required" });
  if (!platformEnabled()) return res.json({ enabled: false });

  const config = { enabled: true, ...platformClientConfig() };
  // Pass first-touch attribution so a player who signs in before your normal login is still credited.
  const user = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
  try {
    if (!user.bazinessUserId) {
      const created = await createUser(deviceId, "Web", "Direct", linkedToken);
      const owner = await db.findUserByBazinessId(created.userId);
      if (owner && owner.id !== user.id) {
        return res.json({ ...config, token: created.token, bazinessUserId: created.userId,
          isLinkedToAccount: created.isLinkedToAccount, switchToDeviceId: owner.deviceId });
      }
      await db.linkBaziness(user.id, created.userId, created.isLinkedToAccount);
      return res.json({ ...config, token: created.token, bazinessUserId: created.userId, isLinkedToAccount: created.isLinkedToAccount });
    }
    const fresh = await getUserAccessToken(user.bazinessUserId);
    if (fresh.isLinkedToAccount !== user.isBazinessLinked) await db.setLinked(user.id, fresh.isLinkedToAccount);
    return res.json({ ...config, token: fresh.token, bazinessUserId: fresh.userId, isLinkedToAccount: fresh.isLinkedToAccount });
  } catch (err) {
    console.error("[platform] token failed:", (err as Error).message);
    return res.json({ ...config, error: "platform_unavailable" }); // never block the boot
  }
});

app.post("/api/platform/login", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const token = String(req.body.token ?? "").trim();
  if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
  if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
  try {
    const bazinessUserId = await validateUserAccessToken(token); // the server-side gate
    const owner = await db.findUserByBazinessId(bazinessUserId);
    const me = await db.ensureUserByDevice(deviceId);
    if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
    await db.linkBaziness(me.id, bazinessUserId, true);
    return res.json({ ok: true });
  } catch {
    return res.json({ ok: false, reason: "invalid" });
  }
});

دادن گیتا کوین

پرداختی که هیچ‌وقت دو بار انجام نمی‌شود: کلید پرداخت transactionId را ثابت می‌کند و 400 یعنی قبلاً پرداخت شده.

// grants.ts — a payout that can never be paid twice.
import { addCoin } from "./platform/baziness";

export async function payTournamentReward(claimId: number, bazinessUserId: number, amount: number) {
  try {
    // Same claim → same key → same transactionId → the platform refuses a second credit.
    await addCoin(bazinessUserId, amount, `reward_claim:${claimId}`, { source: "tournament", claimId });
  } catch (err) {
    // 400 = this transactionId was already credited (e.g. we crashed after the platform paid but
    // before we saved): the coins are there, so carry on instead of failing forever.
    if ((err as { status?: number }).status !== 400) throw err;
  }
  await db.claims.markPaid(claimId);                       // your own bookkeeping
  await db.ledger.insert({ claimId, currency: "GITA_COIN", amount, external: true });
}

دریافت‌ها، سکه‌های امانی و انتقالشان هنگام ورود

قانون‌های گیتا کوین ← بازیکنانی که هنوز وارد نشده‌اند: دریافت جایزه بازیکن وارد شده می‌خواهد، خرید پرداخت‌شده هیچ‌وقت شکست نمی‌خورد، و سکه‌های امانی هنگام ورود بازیکن به بازینس منتقل می‌شوند.

// grants.ts: Gita coins go to the platform; COIN/GEM stay local.
export async function grantCurrency(tx: Tx, change: WalletChange & { idempotencyKey: string },
                                    opts: { requireLink: boolean; metaData?: object }) {
  if (change.currency !== "GITA_COIN" || !platformEnabled() || change.amount < 0) {
    return applyWalletChange(tx, change);                      // local wallet
  }
  const { baziness_user_id: bazinessUserId } = await tx.one("select baziness_user_id from users where id = $1", [change.userId]);
  if (!bazinessUserId) {
    if (opts.requireLink) throw new ApiError("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید");
    return applyWalletChange(tx, { ...change, note: "held until a Baziness account is linked" }); // escrow
  }
  try {
    await addCoin(bazinessUserId, change.amount, change.idempotencyKey, { source: change.type, ...opts.metaData });
  } catch (err) {
    throw new ApiError("PLATFORM_UNAVAILABLE", "ارتباط با بازینس برقرار نشد، دوباره تلاش کنید");
  }
  await recordExternalCredit(tx, change);                      // ledger row, balance unchanged locally
}

/** Called right after a successful link (POST /api/platform/login or /token). */
export async function flushGitaEscrow(userId: number) {
  await withTx(async (tx) => {
    const user = await tx.one("select baziness_user_id from users where id = $1", [userId]);
    const wallet = await tx.oneOrNone(
      "select balance from user_wallets where user_id = $1 and currency = 'GITA_COIN' for update", [userId]);
    if (!user.baziness_user_id || !wallet || wallet.balance <= 0) return;
    const last = await tx.one("select max(id) as id from wallet_transactions where user_id = $1 and currency = 'GITA_COIN'", [userId]);
    const key = `escrow:${userId}:${last.id ?? 0}`;
    await addCoin(user.baziness_user_id, wallet.balance, key, { source: "escrow" });
    await applyWalletChange(tx, { userId, currency: "GITA_COIN", amount: -wallet.balance,
      type: "REWARD_SELECTION", idempotencyKey: key, note: "moved to the Baziness account" });
  });
}

کلاینت پرداخت بازینس

قدم‌های ۱ تا ۵ پرداخت بازینس: آدرس صفحه‌ی پرداخت، get_payment، verify_payment (بدنه‌ی فرم) و consume.

// platform/baziness-pay.ts
const BASE = (process.env.BAZINESS_PAY_BASE_URL ?? "https://pay.baziness.com").replace(/\/+$/, "");
const PNAME = process.env.BAZINESS_PAY_PACKAGE_NAME ?? "";
const PREFIX = process.env.BAZINESS_SKU_PREFIX ?? "";
export const payEnabled = () => process.env.BAZINESS_PAY_ENABLED === "true";

export const skuFor = (productSku: string, override?: string | null) => override || `${PREFIX}${productSku}`;

export const buildBuyUrl = (uid: string, sku: string) =>
  `${BASE}/buy?${new URLSearchParams({ sku, uid, pname: PNAME })}`;

/** The verification token of a completed, unconsumed payment — or null while unpaid. */
export async function getPayment(uid: string, sku: string): Promise<{ token: string; sku: string } | null> {
  try {
    const res = await fetch(`${BASE}/get_payment`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uid, sku, pname: PNAME }),
    });
    if (!res.ok) return null;
    const j = (await res.json()) as { success?: boolean; token?: string; sku?: string };
    return j.success && j.token ? { token: j.token, sku: j.sku ?? sku } : null;
  } catch { return null; }
}

/** The authoritative check. Never trust a token from the client. */
export async function verifyPayment(uid: string, token: string, sku: string): Promise<boolean> {
  try {
    const res = await fetch(`${BASE}/verify_payment`, {
      method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },   // form, not JSON
      body: new URLSearchParams({ uid, token, sku, pname: PNAME }).toString(),
    });
    if (!res.ok) return false;
    const j = (await res.json()) as { success?: boolean; error?: string };
    if (!j.success && j.error) console.warn("[pay] verify_payment rejected:", j.error);
    return j.success === true;
  } catch { return false; }
}

/** Let the SKU be bought again. Best effort, 3 attempts; never blocks an already-granted purchase. */
export async function consumePayment(uid: string, sku: string): Promise<boolean> {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const res = await fetch(`${BASE}/consume`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ uid, sku, pname: PNAME }),
      });
      if (res.ok && ((await res.json()) as { success?: boolean }).success) return true;
    } catch { /* retry */ }
  }
  console.error("[pay] consume failed uid=%s sku=%s", uid, sku);
  return false;
}

جدول پرداخت‌ها

Schema (PostgreSQL)
create table payments (
  id                       bigserial primary key,
  user_id                  bigint not null references users(id),
  shop_product_id          bigint not null references shop_products(id),
  provider                 text not null default 'BAZINESS',
  provider_sku             text not null,
  -- The Baziness payment token; unique so the same payment can never be fulfilled twice.
  provider_transaction_id  text,
  amount                   bigint not null,
  currency_code            text not null,
  status                   text not null default 'CREATED' check (status in
                             ('CREATED','REDIRECTED','PAID','VERIFIED','FAILED','CANCELLED','FULFILLED')),
  reward_snapshot          jsonb not null,          -- what the player was promised when they clicked
  failure_reason           text,
  created_at               timestamptz not null default now(),
  redirected_at            timestamptz,
  verified_at              timestamptz,
  fulfilled_at             timestamptz,
  updated_at               timestamptz not null default now(),
  unique (provider, provider_transaction_id)
);
create index payments_user on payments(user_id, created_at desc);

endpointهای خرید

POST /api/shop/purchase/start و POST /api/shop/purchase/verify: سفارش را ذخیره کنید، بعد تأیید کنید و آیتم را دقیقاً یک بار بدهید.

// services/shop.ts
export async function startPurchase(userId: number, productId: number) {
  if (!payEnabled()) throw new ApiError("PAYMENT_DISABLED", "پرداخت به‌زودی فعال می‌شود");
  const product = await db.one("select * from shop_products where id = $1 and enabled", [productId]);
  const sku = skuFor(product.sku, product.baziness_product_id);
  const payment = await db.one(
    `insert into payments (user_id, shop_product_id, provider_sku, amount, currency_code, status, reward_snapshot, redirected_at)
     values ($1, $2, $3, $4, $5, 'REDIRECTED', $6, now()) returning id`,
    [userId, product.id, sku, product.price, product.currency_code, JSON.stringify(productGrants(product))]);
  return { paymentId: String(payment.id), url: buildBuyUrl(String(userId), sku) };
}

export async function verifyPurchase(userId: number, paymentId: number) {
  const p = await db.oneOrNone("select * from payments where id = $1", [paymentId]);
  if (!p || p.user_id !== userId) throw notFound("payment");
  if (p.status === "FULFILLED" || p.status === "FAILED" || p.status === "CANCELLED") {
    return { status: p.status, wallets: await getBalances(userId), granted: [] };
  }
  const uid = String(userId);
  const found = await getPayment(uid, p.provider_sku);
  if (!found) return { status: p.status, wallets: await getBalances(userId), granted: [] };   // not paid yet
  if (!(await verifyPayment(uid, found.token, p.provider_sku))) {
    await db.none("update payments set failure_reason = 'verify_payment rejected' where id = $1 and status <> 'FULFILLED'", [paymentId]);
    throw new ApiError("PAYMENT_FAILED", "پرداخت تأیید نشد");
  }
  const granted = await fulfilPayment(paymentId, found.token);
  void consumePayment(uid, p.provider_sku);                       // after commit, best effort
  return { status: "FULFILLED", wallets: await getBalances(userId), granted };
}

export function fulfilPayment(paymentId: number, providerToken: string) {
  return withTx(async (tx) => {
    const p = await tx.one("select * from payments where id = $1 for update", [paymentId]);
    if (p.status === "FULFILLED") return [];
    const dup = await tx.oneOrNone(
      "select id from payments where provider = 'BAZINESS' and provider_transaction_id = $1 and id <> $2", [providerToken, paymentId]);
    if (dup) {
      await tx.none("update payments set status = 'FAILED', failure_reason = 'duplicate provider token' where id = $1", [paymentId]);
      return [];
    }
    await tx.none("update payments set status = 'VERIFIED', provider_transaction_id = $2, verified_at = now() where id = $1",
      [paymentId, providerToken]);
    for (const g of p.reward_snapshot) {
      await grantCurrency(tx, { userId: p.user_id, currency: g.currency, amount: g.amount, type: "SHOP_PURCHASE",
        idempotencyKey: `payment:${paymentId}:${g.currency}` }, { requireLink: false }); // Gita coins: escrow if not linked
    }
    await tx.none("update payments set status = 'FULFILLED', fulfilled_at = now() where id = $1", [paymentId]);
    return p.reward_snapshot;
  });
}

درگاه آزمایشی محلی

بازی نمونه یک جایگزین برای پرداخت بازینس دارد که همان پروتکل را حرف می‌زند، برای کار بدون دسترسی به درگاه. با این تنظیمات روشن می‌شود، و هرگز در محیط اصلی:

.env (local only)
DEV_FAKE_BAZINESS_PAY=true
BAZINESS_PAY_ENABLED=true
BAZINESS_PAY_BASE_URL=http://localhost:3390/dev/baziness

انتساب اولین تماس

قانون‌های لینک‌ها و UTM. زبانه‌ی اول سمت کلاینت است (خواندن برچسب‌ها و پاک کردنشان از آدرس)؛ بقیه آن‌ها را هنگام ساخت بازیکن در سرور ذخیره می‌کنند.

// api/client.ts — the FIRST module the app imports.
const UTM_KEY = "mygame_utm";
const TAG_KEY = "mygame_tag";
const UTM_FIELDS = ["source", "medium", "campaign", "content"] as const;
type Utm = Partial<Record<(typeof UTM_FIELDS)[number], string>>;

const clean = (raw: unknown): string | null => {
  if (typeof raw !== "string") return null;
  const v = raw.trim().toLowerCase().replace(/\s+/g, "_").slice(0, 64);
  return /^[a-z0-9][a-z0-9._-]*$/.test(v) ? v : null;      // ASCII only
};

export const FIRST_TOUCH_UTM: Utm = (() => {
  let stored: Utm = {};
  try { stored = JSON.parse(localStorage.getItem(UTM_KEY) ?? "{}"); } catch {}
  try {
    const query = new URLSearchParams(location.search);
    const keys = [...query.keys()].filter((k) => k.toLowerCase().startsWith("utm_"));
    const fromUrl: Utm = {};
    for (const k of keys) {
      const field = k.toLowerCase().slice(4) as keyof Utm;
      const v = clean(query.get(k));
      if (UTM_FIELDS.includes(field as never) && v) fromUrl[field] = v;
    }
    if (keys.length) {                                      // take them out of the address
      keys.forEach((k) => query.delete(k));
      const search = query.toString();
      history.replaceState(history.state, "", `${location.pathname}${search ? `?${search}` : ""}${location.hash}`);
    }
    if (Object.keys(fromUrl).length && !Object.keys(stored).length) {   // first touch wins
      stored = fromUrl;
      localStorage.setItem(UTM_KEY, JSON.stringify(stored));
    }
  } catch { /* storage blocked: the visit is simply untagged */ }
  return stored;
})();

export function firstTouchTag(): string | null {
  try {
    const fromUrl = clean(new URLSearchParams(location.search).get("tag"));
    if (fromUrl && !localStorage.getItem(TAG_KEY)) localStorage.setItem(TAG_KEY, fromUrl);
    return localStorage.getItem(TAG_KEY) ?? fromUrl;
  } catch { return null; }
}

// Sent with the device login AND with /api/platform/token:
//   { deviceId, tag: firstTouchTag(), utm: FIRST_TOUCH_UTM }
Schema (PostgreSQL)
alter table users
  add column if not exists acq_tag      text,
  add column if not exists utm_source   text,
  add column if not exists utm_medium   text,
  add column if not exists utm_campaign text,
  add column if not exists utm_content  text;

create index if not exists idx_users_utm_combo on users (utm_source, utm_medium, utm_campaign, utm_content)
  where utm_source is not null or utm_medium is not null or utm_campaign is not null or utm_content is not null;

لینک صفحه‌های بازینس (پارامترها):

type PageOptions = { redirect?: "shop" | "profile"; allowGuest?: boolean; gameName?: string; buildStore?: string };

export function bazinessPageUrl(webBase: string, path: string, packageName: string, token: string | null, o: PageOptions = {}) {
  const u = new URL(webBase.replace(/\/+$/, "") + (path || "/"));
  if (o.gameName) u.searchParams.set("game_name", o.gameName);
  u.searchParams.set("packageName", packageName);
  u.searchParams.set("sdkType", "js");                      // never optional
  if (token) u.searchParams.set("linkedToken", token);
  if (o.redirect) u.searchParams.set("redirect", o.redirect);
  if (o.allowGuest === false) u.searchParams.set("allowGuest", "false");
  if (o.buildStore) u.searchParams.set("buildStore", o.buildStore);
  return u.toString();
}

// bazinessPageUrl(webBase, "/shop", pkg, token, { gameName: "example", redirect: "shop" })
// bazinessPageUrl(webBase, "/", pkg, token, { allowGuest: false })

لینک کمپین برای بازاریابی:

export function campaignLink(gameUrl: string, utm: { source: string; medium: string; campaign: string; content?: string }) {
  const u = new URL(gameUrl);
  u.searchParams.set("utm_source", utm.source);
  u.searchParams.set("utm_medium", utm.medium);
  u.searchParams.set("utm_campaign", utm.campaign);
  if (utm.content) u.searchParams.set("utm_content", utm.content);
  return u.toString();
}
// campaignLink("https://yourgame.example.com/", { source: "baziness", medium: "inapp", campaign: "shahrivar_1405", content: "banner_home" })
نمونه کد

کد کلاینت

کد آماده‌ی کلاینت (کنترلر پلتفرم، پیام‌های صفحه، موجودی سکه، دکمه‌ی جایزه، صفحه‌ی پیشنهاد آموزش و پنجره‌ی خرید) برای وب، React، Cocos Creator و Unity.

این نمونه‌ها سمت کلاینت راهنماها هستند. بیشترشان زبانه‌های TypeScript (وب)، React و C# (Unity) دارند، و در انتها یک نسخه‌ی کامل Cocos Creator هست. وب‌ویوی داخل بازی (شیت پایین) صفحه‌ی خودش را دارد.

کنترلر پلتفرم

قلب کلاینت: توکن بازیکن را از سرور شما می‌گیرد، لینک همه‌ی صفحه‌ها را می‌سازد، صفحه‌ها را باز می‌کند و انتخاب اجرای اول را در اسپلش انجام می‌دهد (ورود).

// baziness.ts — the client controller.
const TOKEN_KEY = "mygame_baziness_token";
const CHOICE_KEY = "mygame_platform_choice";

export class BazinessPlatform {
  enabled = false;
  isLinked = false;
  private token: string | null = null;
  private webBase = "";
  private apiBase = "";
  private packageName = "";
  private loginGate: ((ok: boolean) => void) | null = null;
  private loginSucceeded = false;
  onChange: (() => void) | null = null;

  constructor(private sheet: BazinessSheet, private deviceId: string) {
    sheet.onMessage = (m) => this.handleMessage(m);
    sheet.onClosed = () => this.handleClosed();
  }

  get choiceMade() { return localStorage.getItem(CHOICE_KEY) === "1"; }
  markChoiceMade() { localStorage.setItem(CHOICE_KEY, "1"); }

  async loadConfig(): Promise<boolean> {
    try {
      const cfg = await fetch("/api/platform/config").then((r) => r.json());
      this.enabled = cfg.enabled;
      if (cfg.enabled) this.apply(cfg);
    } catch { this.enabled = false; }
    return this.enabled;
  }

  /** Resolve the token. Returns false when the page is about to reload into another account. */
  async init(): Promise<boolean> {
    // The Baziness app opens games with ?linkedToken=… — forward it once, then it is stripped on reload.
    const linkedToken = new URLSearchParams(location.search).get("linkedToken") || localStorage.getItem(TOKEN_KEY);
    const resp = await fetch("/api/platform/token", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ deviceId: this.deviceId, linkedToken }),
    }).then((r) => r.json()).catch(() => null);
    if (!resp?.enabled) { this.enabled = false; return true; }
    this.enabled = true;
    this.apply(resp);
    if (resp.switchToDeviceId && resp.switchToDeviceId !== this.deviceId) {
      this.switchAccount(resp.switchToDeviceId);
      return false;
    }
    if (resp.token) {
      this.token = resp.token;
      this.isLinked = !!resp.isLinkedToAccount;
      localStorage.setItem(TOKEN_KEY, resp.token);
    }
    this.onChange?.();
    return true;
  }

  /** Splash "Sign in with Baziness": resolves true once a login/link succeeded, false if closed. */
  async openLoginAndWait(): Promise<boolean> {
    if (!this.token) await this.init(); // the anonymous user's token becomes linkedToken
    if (!this.enabled || !this.webBase) return false;
    return new Promise((resolve) => {
      this.loginGate = resolve;
      this.loginSucceeded = false;
      this.sheet.open(this.pageUrl("/", { allowGuest: false }));
    });
  }

  /** Settings → "Connect your account": the profile page. */
  openAccount() { this.sheet.open(this.pageUrl("/profile", { redirect: "profile", allowGuest: false })); }

  pageUrl(path: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
    const u = new URL(this.webBase + path);
    for (const [k, v] of Object.entries(o.extra ?? {})) u.searchParams.set(k, v);
    u.searchParams.set("packageName", this.packageName);
    u.searchParams.set("sdkType", "js");
    if (this.token) u.searchParams.set("linkedToken", this.token);
    if (o.redirect) u.searchParams.set("redirect", o.redirect);
    if (o.allowGuest === false) u.searchParams.set("allowGuest", "false");
    return u.toString();
  }

  private apply(c: { webBase?: string; apiBase?: string; packageName?: string }) {
    this.webBase = c.webBase ?? ""; this.apiBase = c.apiBase ?? ""; this.packageName = c.packageName ?? "";
  }
  // handleMessage / handleClosed / switchAccount: see "Messages from the Baziness page" below.
}

رسیدگی به پیام‌های صفحه

close، login، token و logout، همان‌طور که در صفحه‌های بازینس ← پیام‌ها آمده.

// Inside BazinessPlatform (continued).
const extractToken = (data: unknown): string | undefined =>
  typeof data === "string" ? data
  : data && typeof (data as { token?: unknown }).token === "string" ? (data as { token: string }).token
  : undefined;

handleMessage(msg: { type: string; data?: unknown }) {
  const token = extractToken(msg.data);
  switch (msg.type) {
    case "close":
      this.sheet.close();
      break;
    case "login":
      if (token) void this.applyNewLogin(token);   // signed into an account → switch or link
      else void this.linkedInPlace();              // linked onto the current player
      break;
    case "token":
      break;                                       // silent handshake — NOT a login
    case "logout":
      this.sheet.close();
      localStorage.clear();                        // identity, token, choice, tutorial flags…
      this.reloadClean();                          // next boot = brand-new visitor
      break;
  }
}

private async applyNewLogin(token: string) {
  const result = await fetch("/api/platform/login", {
    method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId, token }),
  }).then((r) => r.json());
  if (result.switchToDeviceId && result.switchToDeviceId !== this.deviceId) {
    this.markChoiceMade();
    this.switchAccount(result.switchToDeviceId);   // reloads
    return;
  }
  if (result.ok) {
    localStorage.setItem(TOKEN_KEY, token);
    await this.init();                             // fresh token + linked flag
    this.loginSucceeded = true;
    this.markChoiceMade();
    this.sheet.close();                            // handleClosed resolves the login gate
  }
  // on failure keep the page open so the player can retry
}

private async linkedInPlace() {
  this.isLinked = true;
  this.loginSucceeded = true;
  this.markChoiceMade();
  if (this.loginGate) { await this.init(); this.sheet.close(); }
  this.onChange?.();
}

private handleClosed() {
  const gate = this.loginGate;
  this.loginGate = null;
  gate?.(this.loginSucceeded);                     // true only if a login actually happened
  this.onChange?.();                               // coin balance / link state may have changed
}

private switchAccount(deviceId: string) {
  setDeviceId(deviceId);                           // your own identity store
  localStorage.removeItem(TOKEN_KEY);
  this.reloadClean();
}

private reloadClean() {
  const url = new URL(location.href);
  url.searchParams.delete("linkedToken");          // never re-apply a stale token
  url.searchParams.delete("device");               // a ?device= test override would undo the switch
  location.replace(url.toString());
}

نمایش موجودی

// Inside BazinessPlatform: the player's Gita coin balance, straight from the platform. null = unknown.
async getCoin(): Promise<number | null> {
  if (!this.enabled || !this.token || !this.apiBase) return null;
  try {
    const res = await fetch(`${this.apiBase}/user/get-coin`, {
      headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
    });
    if (!res.ok) return null;
    const json = (await res.json()) as { amount?: number };
    return typeof json.amount === "number" ? json.amount : null;
  } catch {
    return null;
  }
}

// Wiring: refresh on every relevant moment.
baziness.onChange = () => void baziness.getCoin().then(renderCoinChip);   // sheet closed, login, link
window.addEventListener("focus", () => void baziness.getCoin().then(renderCoinChip));

دکمه‌ی جایزه

// Inside BazinessPlatform. GAME_NAME comes from /api/platform/config (or a constant).
openRewardSelection(): void {
  if (!this.enabled || !this.webBase) return;
  this.sheet.open(
    this.pageUrl("/shop", { redirect: "shop", extra: { game_name: this.gameName } }),
    BAZINESS_BOTTOM_BAR_CROP_PX, // optional: hide the platform's own tab bar (see below)
  );
}

// The lobby banner
rewardBanner.onclick = () => {
  if (baziness.enabled) baziness.openRewardSelection();
  else toast("انتخاب جایزه بعد از اتصال به بازینس فعال می‌شود");
};

پنهان کردن نوار پایین

روش صفحه‌ی جایزه: فریم را بلندتر از نما کنید و بگذارید نما آن را برش بدهد.

TypeScript (web)
/** Pixels cropped off the bottom of the embedded page. 0 = show the platform's tab bar. */
export const BAZINESS_BOTTOM_BAR_CROP_PX = 0;

// in BazinessSheet.open(url, cropBottomPx):
this.frame.style.marginBottom = cropBottomPx > 0 ? `${-cropBottomPx}px` : "";

صفحه‌ی پیشنهاد آموزش

صفحه‌ی پیشنهاد را یک بار وسط آموزش باز کنید و وقتی بسته شد بسته‌ی شروع را بدهید (صفحه‌ی پیشنهاد آموزش).

// Inside BazinessPlatform.
gameOfferUrl = "";
private closeListeners = new Set<() => void>();

/** Run fn whenever the sheet closes; returns the unsubscribe function. */
onSheetClosed(fn: () => void) { this.closeListeners.add(fn); return () => this.closeListeners.delete(fn); }
// …and in handleClosed(): for (const fn of [...this.closeListeners]) fn();

get canOpenSheet() { return this.enabled && !!this.webBase; }

/** Onboarding only. Adds the SDK parameters to the server-provided URL. */
openGameOffer() {
  if (!this.gameOfferUrl) return;
  const u = new URL(this.gameOfferUrl);                 // already has ?game_name=
  u.searchParams.set("packageName", this.packageName);
  u.searchParams.set("sdkType", "js");                  // or its close button can't close the sheet
  if (this.token) u.searchParams.set("linkedToken", this.token);
  u.searchParams.set("allowGuest", "false");
  this.sheet.open(u.toString());
}

// Lobby: the coach-marked banner
function onRewardBannerTap() {
  if (onboarding.stage === "rewards") {
    onboarding.setStage("pack");                        // persist first: a reload resumes at the pack
    if (baziness.canOpenSheet && baziness.gameOfferUrl) {
      const off = baziness.onSheetClosed(() => { off(); showStarterPack(); });
      baziness.openGameOffer();
    } else {
      showStarterPack();                                // platform off: never block onboarding
    }
    return;
  }
  baziness.openRewardSelection();                        // after onboarding: the real shop
}

پنجره‌ی خرید

تب را داخل همان ضربه باز کنید، خرید را در سرور شروع کنید، تب را به صفحه‌ی پرداخت ببرید و وقتی بازیکن برگشت بررسی کنید (پرداخت بازینس).

// purchase.ts — the purchase popup.
export function buy(productId: string, ui: PurchaseUi) {
  const tab = window.open("about:blank", "_blank");           // 1. synchronously, no "noopener"
  let paymentId: string | null = null;
  let started = false;
  let verifying = false;

  const verify = async (manual: boolean) => {
    if (!started || verifying || !paymentId) return;
    verifying = true;
    ui.showChecking();
    try {
      const r = await api("POST", "/api/shop/purchase/verify", { paymentId });
      if (r.status === "FULFILLED") { cleanup(); ui.success(r.granted); void refreshWallets(); return; }
      if (manual) ui.toast("پرداختی پیدا نشد. اگر پرداخت کرده‌ای چند لحظه بعد دوباره بررسی کن.");
      ui.showWaiting();
    } catch (err) {
      if (manual) ui.error(err);
      ui.showWaiting();
    } finally { verifying = false; }
  };

  const onFocus = () => void verify(false);
  const onVisible = () => document.visibilityState === "visible" && void verify(false);
  const cleanup = () => { removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); };
  addEventListener("focus", onFocus);
  document.addEventListener("visibilitychange", onVisible);

  ui.showOpening(() => verify(true) /* «پرداخت کردم» */, () => { void verify(false); cleanup(); } /* close */);
  (async () => {
    try {
      const r = await api("POST", "/api/shop/purchase/start", { productId });   // 2.
      paymentId = r.paymentId;
      if (tab && !tab.closed) tab.location.href = r.url;
      else window.open(r.url, "_blank", "noopener");
      started = true;
      ui.showWaiting();                                                     // 3.
    } catch (err) {
      tab?.close(); cleanup(); ui.error(err);
    }
  })();
}

Cocos Creator

یک مجموعه‌ی کامل برای Cocos Creator 3.x در سه فایل:

فایل نقش
BazinessSDK.ts یک کامپوننت ماندگار: صفحه‌ها را باز می‌کند، پیام‌هایشان را مسیریابی می‌کند و موجودی را می‌خواند
BazinessWebView.ts کامپوننت روی پری‌فب WebView: لایه‌ی بارگذاری، دکمه‌ی بستن، انیمیشن‌های لغزیدن، پیام‌ها
BazinessController.ts اتصال‌های زمان بالا آمدن: linkedToken از آدرس، /api/platform/token، ورود، خروج، جابه‌جایی حساب

پری‌فب یک WebView، یک نود لایه‌ی بارگذاری، یک دکمه‌ی بستن و یک Animation با کلیپ‌های Open و Close (لغزیدن به بالا و پایین) لازم دارد. در بیلد وب، WebView یک iframe است، پس پیام‌های صفحه به شکل رویداد message روی window می‌رسند.

// BazinessSDK.ts — a persistent component (put it on a node in the first scene).
// Opens Baziness pages in a WebView prefab, routes their messages and reads the Gita coin balance.
import { _decorator, Component, director, find, instantiate, Prefab, EventTarget } from "cc";
import { BazinessWebView } from "./BazinessWebView";
const { ccclass, property } = _decorator;

export const bazinessEvents = new EventTarget(); // "login" | "logout" | "closed"

const WEB = { dev: "https://platform-web-dev.karizmastudios.org", prod: "https://baziness.com" };
const API = { dev: "https://platform-dev.karizmastudios.org/api", prod: "https://platform.karizmastudio.com/api" };

@ccclass("BazinessSDK")
export class BazinessSDK extends Component {
  @property(Prefab) webViewPrefab: Prefab = null!;

  static instance: BazinessSDK;
  static packageName = "com.yourstudio.yourgame";
  static devMode = true;
  static token: string | null = null; // the player's user token, from YOUR server
  static isLinked = false;

  private view: BazinessWebView | null = null;

  onLoad() {
    BazinessSDK.instance = this;
    director.addPersistRootNode(this.node);
  }

  /** Call after your server answered /api/platform/token. */
  static initialize(token: string, isLinked: boolean) {
    this.token = token;
    this.isLinked = isLinked;
  }

  static openLogin() {
    this.instance.open(`${this.web()}/`, { allowGuest: false });
  }
  static openProfile() {
    this.instance.open(`${this.web()}/profile`, { redirect: "profile", allowGuest: false });
  }
  static openRewards(gameName: string) {
    this.instance.open(`${this.web()}/shop`, { redirect: "shop", extra: { game_name: gameName } });
  }

  /** The Gita coin balance, straight from the platform with the player's own token. */
  static async getCoin(): Promise<number | null> {
    if (!this.token) return null;
    try {
      const res = await fetch(`${this.devMode ? API.dev : API.prod}/user/get-coin`, {
        headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
      });
      return res.ok ? ((await res.json()).amount ?? null) : null;
    } catch {
      return null;
    }
  }

  private static web() {
    return this.devMode ? WEB.dev : WEB.prod;
  }

  private open(base: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
    if (this.view) return; // one page at a time
    const q = new URLSearchParams(o.extra);
    q.set("packageName", BazinessSDK.packageName);
    q.set("sdkType", "js"); // the page posts close / login / logout / token
    if (BazinessSDK.token) q.set("linkedToken", BazinessSDK.token);
    if (o.redirect) q.set("redirect", o.redirect);
    if (o.allowGuest === false) q.set("allowGuest", "false");

    const node = instantiate(this.webViewPrefab);
    node.setParent(find("Canvas"));
    this.view = node.getComponent(BazinessWebView)!;
    this.view.onMessage = (m) => this.onMessage(m);
    this.view.onClosed = () => {
      this.view = null;
      bazinessEvents.emit("closed");
    };
    this.view.open(`${base}?${q}`);
  }

  private onMessage(msg: { type: string; data?: unknown }) {
    const token = typeof msg.data === "string" ? msg.data : (msg.data as { token?: string } | undefined)?.token;
    switch (msg.type) {
      case "close":
        this.view?.close();
        break;
      case "login":
        if (token) {
          this.view?.close(true);
          bazinessEvents.emit("login", token); // → POST /api/platform/login on your server
        } else {
          BazinessSDK.isLinked = true; // linked in place
        }
        break;
      case "logout":
        this.view?.close(true);
        bazinessEvents.emit("logout");
        break;
      case "token":
        break; // the page's silent handshake — not a login
    }
  }
}
نمونه کد

شیت پایین (وب‌ویوی داخل بازی)

شیت کشویی که همه‌ی صفحه‌های بازینس را داخل بازی نشان می‌دهد. از پایین بالا می‌آید، با کشیدن، زدن بیرون یا دکمه‌ی خود صفحه بسته می‌شود و پیام‌های صفحه را به بازی می‌رساند.

چرا شیت

همه‌ی صفحه‌های بازینس (ورود، پروفایل، فروشگاه جایزه، پیشنهاد بازی) داخل بازی باز می‌شوند، نه در تب جدید. ظرف پیشنهادی یک شیت پایین است: روی بازی تیره‌شده از پایین بالا می‌آید، کمی پایین‌تر از بالای صفحه می‌ایستد و لبه‌ی بالایی گرد با یک دستگیره دارد. بازیکن به سه روش می‌تواند آن را ببندد و خود صفحه هم می‌تواند.

امتحانش کنید:

دستگیره را به پایین بکشید، روی ناحیه‌ی تیره بزنید یا دکمه‌ی بستن صفحه را بزنید.

اجزا

Text
┌──────────────────────────────────────┐
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│  ← لایه‌ی تیره (زدن = بستن)  rgba(8,6,4,.5) · z-index 1100
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│
│ ╭──────────────────────────────────╮ │  ← شیت: ارتفاع ۹۲٪، حداکثر عرض 560px
│ │              ━━━━━               │ │     گوشه‌ها 22px 22px 0 0
│ │                                  │ │  ← ناحیه‌ی دستگیره 26px — تنها جای کشیدنی
│ ├──────────────────────────────────┤ │
│ │                                  │ │
│ │     <iframe src="baziness…">     │ │  ← بدون sandbox؛ spinner تا رویداد load
│ │                                  │ │
│ │                                  │ │
╰─┴──────────────────────────────────┴─╯

رفتارهای لازم

  1. باز شدن: شیت را زیر صفحه بکشید و در فریم بعد به بالا متحرک کنید (حدود ۳۲۰ میلی‌ثانیه، ease-out).
  2. سه راه بستن:
    • کشیدن دستگیره به پایین بیشتر از min(150px, 28% ارتفاع شیت)؛ کشیدن کوتاه‌تر شیت را به جایش برمی‌گرداند
    • زدن روی ناحیه‌ی تیره‌ی بالای شیت
    • صفحه پیام { "type": "close" } بفرستد
  3. فقط دستگیره کشیدنی است. صفحه لمس‌های خودش را می‌بلعد، پس کشیدنی که داخل صفحه شروع شود شیت را تکان نمی‌دهد.
  4. روی iframe sandbox نگذارید. صفحه‌ی ورود به کوکی‌ها، پنجره‌های بازشو (برای کد) و redirect خودش نیاز دارد. clipboard-read; clipboard-write; payment را مجاز کنید.
  5. تا بارگذاری صفحه یک spinner نشان دهید.
  6. پیام‌ها را فقط از origin خود صفحه بپذیرید. هم JSON رشته‌ای و هم شیء ساده را بخوانید و type رشته‌ای را الزامی کنید.
  7. تمیز ببندید: به پایین متحرک کنید، بعد فقط یک بار جمعش کنید: فریم را به about:blank ببرید، گوش دادن به پیام‌ها را قطع کنید و بعد «بسته شد» را اعلام کنید. اگر در این فاصله شیت دوباره باز شده، کاری نکنید.
  8. بالای همه‌چیز باشد، اسپلش هم: صفحه‌ی ورود ممکن است از اسپلش باز شود.
  9. اندازه‌اش مثل ستون بازی باشد: در گوشی تمام‌عرض، در صفحه‌های پهن حداکثر حدود ۵۶۰ پیکسل و وسط‌چین.

وب: TypeScript، React و CSS

// bazinesswebview.ts — the sheet, framework-free.
export interface BazinessMessage { type: string; data?: unknown }

export class BazinessSheet {
  readonly el: HTMLElement;
  private sheet: HTMLElement;
  private frame: HTMLIFrameElement;
  private spinner: HTMLElement;
  private allowedOrigin: string | null = null;
  private isOpen = false;
  private dragging = false;
  private dragStartY = 0;
  private dragDelta = 0;
  private sheetHeight = 1;

  onMessage: ((m: BazinessMessage) => void) | null = null;
  onClosed: (() => void) | null = null;

  constructor() {
    this.frame = Object.assign(document.createElement("iframe"), { className: "baziness-frame" });
    this.frame.setAttribute("allow", "clipboard-read; clipboard-write; payment"); // no sandbox!
    this.spinner = Object.assign(document.createElement("div"), { className: "baziness-spinner" });
    const grabberZone = document.createElement("div");
    grabberZone.className = "baziness-grabber-zone";
    grabberZone.innerHTML = '<div class="baziness-grabber"></div>';
    this.sheet = document.createElement("div");
    this.sheet.className = "baziness-sheet";
    this.sheet.append(grabberZone, this.spinner, this.frame);
    this.el = document.createElement("div");
    this.el.className = "overlay baziness-webview hidden";
    this.el.append(this.sheet);
    document.body.append(this.el);

    this.frame.addEventListener("load", () => this.spinner.classList.add("hidden"));
    grabberZone.addEventListener("pointerdown", (e) => this.dragStart(e));
    grabberZone.addEventListener("pointermove", (e) => this.dragMove(e));
    grabberZone.addEventListener("pointerup", () => this.dragEnd());
    grabberZone.addEventListener("pointercancel", () => this.dragEnd());
    this.el.addEventListener("pointerdown", (e) => { if (e.target === this.el) this.close(); }); // tap outside
  }

  open(url: string, cropBottomPx = 0): void {
    if (this.isOpen) return;
    this.isOpen = true;
    this.frame.style.marginBottom = cropBottomPx > 0 ? `${-cropBottomPx}px` : "";
    this.allowedOrigin = new URL(url).origin;
    this.spinner.classList.remove("hidden");
    this.el.classList.remove("hidden");
    this.sheet.style.transition = "none";
    this.sheet.style.transform = "translateY(100%)";
    this.frame.src = url;
    window.addEventListener("message", this.onWindowMessage);
    requestAnimationFrame(() => {
      this.sheet.style.transition = "";
      this.sheet.style.transform = "translateY(0)";
    });
  }

  close(): void {
    if (!this.isOpen) return;
    this.isOpen = false;
    this.dragging = false;
    window.removeEventListener("message", this.onWindowMessage);
    this.sheet.style.transition = "";
    this.sheet.style.transform = "translateY(100%)";
    let torn = false;
    const teardown = () => {
      if (torn || this.isOpen) return;          // already torn down, or reopened meanwhile
      torn = true;
      this.sheet.removeEventListener("transitionend", teardown);
      this.el.classList.add("hidden");
      this.frame.src = "about:blank";
      this.frame.style.marginBottom = "";
      this.sheet.style.transform = "";
      this.allowedOrigin = null;
      this.onClosed?.();
    };
    this.sheet.addEventListener("transitionend", teardown);
    window.setTimeout(teardown, 400);
  }

  private dragStart(e: PointerEvent) {
    this.dragging = true;
    this.dragStartY = e.clientY;
    this.dragDelta = 0;
    this.sheetHeight = this.sheet.getBoundingClientRect().height || 1;
    this.sheet.style.transition = "none";
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
  }
  private dragMove(e: PointerEvent) {
    if (!this.dragging) return;
    this.dragDelta = Math.max(0, e.clientY - this.dragStartY);   // downward only
    this.sheet.style.transform = `translateY(${this.dragDelta}px)`;
  }
  private dragEnd() {
    if (!this.dragging) return;
    this.dragging = false;
    this.sheet.style.transition = "";
    if (this.dragDelta > Math.min(150, this.sheetHeight * 0.28)) this.close();
    else this.sheet.style.transform = "translateY(0)";           // spring back
  }

  private onWindowMessage = (event: MessageEvent) => {
    if (this.allowedOrigin && event.origin !== this.allowedOrigin) return;
    let value: unknown = event.data;
    if (typeof value === "string") { try { value = JSON.parse(value); } catch { return; } }
    if (value && typeof value === "object" && typeof (value as BazinessMessage).type === "string") {
      this.onMessage?.(value as BazinessMessage);
    }
  };
}

Cocos Creator

کامپوننت BazinessWebView در کد کلاینت ← Cocos Creator همین کار را با یک پری‌فب WebView انجام می‌دهد: لایه‌ی بارگذاری تا لود شدن صفحه، دکمه‌ی بستنی که بعد از چند ثانیه ظاهر می‌شود، و انیمیشن‌های لغزیدن Open / Close.

Unity

Unity وب‌ویوی داخلی ندارد و به پلاگین نیاز دارید. این طرح از gree/unity-webview استفاده می‌کند: صفحه ۸٪ پایین‌تر از بالای صفحه شروع می‌شود، دستگیره یک المان UI یونیتی بالای آن است و پیام‌های صفحه با یک پل کوچک جاوااسکریپتی به C# می‌رسند.

C# (Unity)
// BazinessSheet.cs — a bottom sheet around a WebView plugin (gree/unity-webview).
using System;
using UnityEngine;
using UnityEngine.EventSystems;

public class BazinessSheet : MonoBehaviour, IDragHandler, IEndDragHandler   // put this on the grabber UI
{
    [SerializeField] RectTransform grabber;    // a 26px bar drawn by Unity above the web view
    [SerializeField] GameObject dimmer;        // full-screen button behind; OnClick → Close()
    WebViewObject web;
    Action<string> onMessage;
    Action onClosed;
    float topMargin, drag;

    public void Open(string url, Action<string> onMessage, Action onClosed = null)
    {
        this.onMessage = onMessage;
        this.onClosed = onClosed;
        topMargin = Screen.height * 0.08f;     // the sheet stops 8% short of the top
        drag = 0;
        web = new GameObject("BazinessWebView").AddComponent<WebViewObject>();
        web.Init(
            cb: msg => this.onMessage?.Invoke(msg),   // messages forwarded by the bridge below
            ld: _ => web.EvaluateJS(
                "window.addEventListener('message',function(e){" +
                "Unity.call(typeof e.data==='string'?e.data:JSON.stringify(e.data));});"));
        Layout();
        web.LoadURL(url);
        web.SetVisibility(true);
        dimmer.SetActive(true);
        grabber.gameObject.SetActive(true);
    }

    public void OnDrag(PointerEventData e) { drag = Mathf.Max(0, drag - e.delta.y); Layout(); }

    public void OnEndDrag(PointerEventData e)
    {
        if (drag > Mathf.Min(150, (Screen.height - topMargin) * 0.28f)) Close();
        else { drag = 0; Layout(); }           // spring back
    }

    public void Close()
    {
        if (web == null) return;
        Destroy(web.gameObject);
        web = null;
        dimmer.SetActive(false);
        grabber.gameObject.SetActive(false);
        onClosed?.Invoke();
    }

    void Layout()
    {
        int top = (int)(topMargin + drag + grabber.rect.height);
        web.SetMargins(0, top, 0, 0);
        grabber.anchoredPosition = new Vector2(0, -(topMargin + drag));
    }
}

اشتباه‌های رایج

اشتباه نتیجه
sandbox روی iframe ورود گیر می‌کند: پنجره‌های بازشو و کوکی‌ها مسدود می‌شوند
کل شیت کشیدنی است کشیدن داخل صفحه هم صفحه را اسکرول می‌کند و هم شیت را تکان می‌دهد، یا هیچ چیز تکان نمی‌خورد
بررسی origin برای پیام‌ها نیست پیامی از جای دیگر به جای پیام صفحه گرفته می‌شود
«بسته شد» دو بار اعلام می‌شود (پایان انیمیشن + زمان‌سنج) ورودی که در انتظار است دو بار تمام می‌شود؛ بسته‌ی شروع دو بار باز می‌شود
شیت زیر اسپلش است صفحه‌ی ورود نادیده پشت اسپلش باز می‌شود
فریم به about:blank برنمی‌گردد صفحه‌ی قبلی در پس‌زمینه به اجرا ادامه می‌دهد
بازی نمونه

بازی نمونه — سنگ، کاغذ، قیچی

یک بازی کامل و کوچک که همه‌ی یکپارچه‌سازی‌های این ویکی را دارد. روی سرور توسعه بازی‌اش کنید، پروژه را دانلود کنید و در دو دقیقه خودتان اجرایش کنید.

بازی کنید

باز کردن بازی نمونه (سرورهای توسعه: platform-dev، platform-web-dev، pay-dev).

دانلود پروژه (.zip): Node.js و جاوااسکریپت مرورگر ساده، بدون هیچ وابستگی و بدون مرحله‌ی بیلد.

چه چیزهایی را نشان می‌دهد

یک مسابقه‌ی سنگ، کاغذ، قیچی «بهترین از ۵ دست» در برابر ربات: اولین کسی که ۳ امتیاز بگیرد برنده است و دست مساوی امتیاز ندارد. هر مسابقه ۱ بلیت هزینه دارد و برنده ۵۰ گیتا کوین می‌گیرد. همه‌ی قابلیت‌های پلتفرم در آن هست:

قابلیت کجای بازی صفحه‌ی ویکی کد
ورود با بازینس (انتخاب اسپلش)، جابه‌جایی حساب، خروج اجرای اول؛ تنظیمات ← «اتصال حساب» ورود public/js/platform.js، server/services.js (platformToken، platformLogin)
ارزها: بلیت (محلی) و گیتا کوین (get-coin) نشان‌های نوار بالا، بنر جایزه گیتا کوین public/js/platform.js (getCoin)، public/js/app.js (renderTopbar)
۵۰ گیتا کوین برای برنده (add-coin با کلید یکتا)، برای دریافت ورود لازم است پنجره‌ی نتیجه، نوار «جایزه منتظر توست» گیتا کوین server/services.js (claimMatchReward)، public/js/app.js (claim)
صفحه‌ی «انتخاب جایزه» بنر بنفش صفحه‌ی جایزه public/js/platform.js (openRewardSelection)
آموزش: قانون‌ها ← راهنما روی «انتخاب جایزه» ← صفحه‌ی game-offer ← «خرید موفق» بسته‌ی شروع (۵ بلیت) اجرای اول صفحه‌ی پیشنهاد آموزش public/js/app.js (بخش آموزش)، server/services.js (claimStarterPack)
شیت کشویی همه‌ی صفحه‌های بازینس شیت پایین public/js/sheet.js، public/styles.css
پرداخت بازینس: «بسته‌ی بلیت»، ۱۰ بلیت به ۴٬۹۰۰ تومان (SKU example_pack) کارت فروشگاه پرداخت بازینس server/pay.js، server/services.js (startPurchase، verifyPurchase)، public/js/purchase.js
گرفتن UTM و ?tag= به‌عنوان اولین تماس هر لینکی، مثلاً …/example/?utm_source=wiki&utm_medium=docs لینک‌ها و UTM public/js/api.js (FIRST_TOUCH)، server/services.js (ensureUser)

اجرای محلی

Shell
unzip baziness-example-rps.zip && cd baziness-example-rps
cp .env.example .env        # fill in BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET
npm start                   # → http://127.0.0.1:4380/

پیش‌نیاز: Node.js 20 یا بالاتر. داده‌ها در data/db.json نگه داشته می‌شوند، یک ذخیره‌ساز JSON کوچک به جای دیتابیس شما.

.env مقدار توسعه
BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET کلاینت توسعه‌ی نمونه (از تیم پلتفرم بگیرید، یا کلاینت بازی خودتان)
BAZINESS_PLATFORM_PACKAGE_NAME com.karizmastudio.example
BAZINESS_PAY_BASE_URL https://pay-dev.karizmastudios.org
BAZINESS_PAY_PACKAGE_NAME com.karizmastudio.example
PACK_SKU / PACK_PRICE example_pack / 4900

پیشنهاد

کلید ندارید؟ باز هم اجرا می‌شود. با BAZINESS_PLATFORM_ENABLED=false رابط بازینس پنهان می‌شود و جایزه‌ها محلی نگه داشته می‌شوند. با DEV_FAKE_BAZINESS_PAY=true و BAZINESS_PAY_BASE_URL=http://localhost:4380/dev/baziness، خریدها از یک درگاه آزمایشی داخلی با دکمه‌ی «پرداخت موفق» رد می‌شوند.

نکته‌های تست

  • ورود: روی این نسخه‌ی توسعه، «ورود با حساب بازینس» هر شماره موبایلی را با کد 111111 می‌پذیرد. پیامکی فرستاده نمی‌شود.
  • دوباره دیدن آموزش: از «بازنشانی این دستگاه» پایین لابی استفاده کنید. حافظه‌ی محلی پاک می‌شود و بار بعد بازیکن کاملاً جدید است.
  • بازیکن دوم در همان مرورگر: ?device=someone-else را به آدرس اضافه کنید.
  • دریافت جایزه به‌عنوان مهمان: بدون ورود یک مسابقه را ببرید. درخواست دریافت PLATFORM_NOT_LINKED برمی‌گرداند و شیت ورود باز می‌شود. بعد از ورود، دریافت خودش دوباره تلاش می‌کند.
  • خریدی که بعداً تمام می‌شود: خرید را شروع کنید، پرداخت کنید، بازی را ببندید و دوباره باز کنید. لابی پرداخت‌های در انتظار را تطبیق می‌دهد.
  • لینک کمپین: …/example/?utm_source=wiki&utm_campaign=test را باز کنید. آدرس بعد از بارگذاری تمیز است و برچسب‌ها هنگام ساخت حساب روی آن ذخیره می‌شوند.

ساختار پروژه

Text
baziness-example-rps/
├── .env.example          every setting, documented
├── package.json          "npm start" — no dependencies
├── server/
│   ├── index.js          HTTP routes + static files (relative paths: works at / or /example/)
│   ├── env.js            configuration (.env loader)
│   ├── platform.js       platform client: auth, create-user, tokens, add-coin   ← secret stays here
│   ├── pay.js            Baziness Pay: buy URL, get_payment, verify_payment (form), consume
│   ├── services.js       accounts & linking, match rules, rewards, starter pack, purchases
│   ├── store.js          JSON-file store (swap for your database)
│   └── fake-pay.js       local fake gateway (never in production)
└── public/
    ├── index.html, styles.css
    ├── assets/           art (hands, bot, pack, prize, Gita coin, ticket, Baziness logo)
    └── js/
        ├── api.js        first module: UTM capture, device id, fetch wrapper
        ├── platform.js   BazinessPlatform: page URLs, messages, login, get-coin
        ├── sheet.js      the swipe-down sheet
        ├── purchase.js   Pay popup: tab opened in the click, verify on focus
        ├── ui.js         DOM helpers, Persian numbers, toasts, modals
        └── app.js        splash, lobby, match, tutorial

میزبانی زیر یک مسیر

کلاینت فقط از آدرس‌های نسبی (api/…، assets/…) استفاده می‌کند، پس همین فایل‌ها هم در / و هم زیر یک پیشوند کار می‌کنند. روی سرور توسعه‌ی ویکی، nginx پیشوند /example/ را برمی‌دارد و درخواست را به پروسه‌ی Node می‌فرستد:

NGINX
location = /example { return 301 /example/; }
location /example/ {
    proxy_pass http://127.0.0.1:4380/;          # trailing slash: /example/api/me → /api/me
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
مرجع

چک‌لیست انتشار و رفع اشکال

هر چیزی که باید پیش از روشن کردن بازینس در محیط اصلی بررسی کنید، و راه‌حل رایج‌ترین مشکلات یکپارچه‌سازی.

پیش از انتشار

تنظیمات

  • شناسه و کلید محرمانه‌ی محیط اصلی (جدا از توسعه)، فقط روی سرور.
  • BAZINESS_PLATFORM_ENABLED=true، BAZINESS_PLATFORM_USE_PRODUCTION=true، و نام بسته‌ی پلتفرم و نام بازی تنظیم شده.
  • BAZINESS_PAY_ENABLED=true، BAZINESS_PAY_BASE_URL=https://pay.baziness.com و نام بسته‌ی پرداخت تنظیم شده؛ درگاه آزمایشی محلی خاموش.
  • بیلد کلاینت را برای کلید محرمانه جست‌وجو کنید: نباید آنجا باشد.
  • /api/platform/config مقدار enabled: true را با آدرس‌های محیط اصلی برمی‌گرداند.

ورود

روی پلتفرم توسعه همه‌ی حالت‌ها را با شماره‌های ساختگی و کد 111111 تست کنید، چون پیامکی فرستاده نمی‌شود (جزئیات).

  • اسپلش «ورود با حساب بازینس» / «ادامه به عنوان مهمان» را برای هر دستگاه یک بار می‌پرسد.
  • بستن صفحه‌ی ورود بدون وارد شدن، دو دکمه را برمی‌گرداند.
  • ورود به حسابی که روی دستگاه دیگری استفاده شده، به آن جابه‌جا می‌کند و پیشرفتش سر جایش است.
  • تنظیمات ← اتصال حساب، صفحه‌ی پروفایل را باز می‌کند و بعدش «متصل» نشان می‌دهد.
  • خروج داخل یک صفحه‌ی بازینس، بازی را به حالت یک بازدیدکننده‌ی کاملاً جدید برمی‌گرداند.
  • باز کردن بازی از اپ بازینس (?linkedToken=) آموزش را برای بازیکن جدید اجرا می‌کند.

سکه و صفحه‌ها

  • موجودی گیتا کوین از پلتفرم می‌آید: تا وقتی معلوم نیست …، هیچ‌وقت یک 0 ساختگی.
  • موجودی بعد از بسته شدن صفحه و بعد از فوکوس دوباره‌ی بازی تازه می‌شود.
  • هر پرداخت یک‌باره transactionIdای دارد که از ماهیت پرداخت ساخته شده، و 400 پرداخت‌شده حساب می‌شود.
  • «انتخاب جایزه» /shop?game_name=… را بدون دیوار ورود باز می‌کند.
  • (اگر استفاده می‌کنید) ضربه‌ی اول آموزش /game-offer را باز می‌کند و بستنش به بسته‌ی شروع می‌رسد.
  • نما با دستگیره، با زدن بیرون و با دکمه‌ی خود صفحه بسته می‌شود.
  • لینک هر صفحه packageName و sdkType=js و در صورت وجود linkedToken را دارد.
  • لینک‌های کمپین هر چهار utm_* را به ASCII دارند و نوار آدرس بعد از بارگذاری تمیز است.
  • کمپین بعد از ورود بازینس و جابه‌جایی حساب حفظ می‌شود.

پرداخت بازینس

  • همه‌ی SKUها زیر نام بسته‌ی پرداخت شما ثبت شده‌اند.
  • یک خرید واقعی در محیط اصلی یک بار تحویل و بعد consume شده.
  • خریدن دو بار پشت سر هم همان بسته کار می‌کند، که نشان می‌دهد consume اجرا شده.
  • پرداختی که بعد از بستن بازی تمام شده، دفعه‌ی بعد که فروشگاه باز می‌شود تحویل داده می‌شود.

رفع اشکال

نشانه علت احتمالی راه‌حل
دکمه‌ی بستن صفحه کاری نمی‌کند sdkType=js نیست، یا بررسی origin پیام با origin اشتباه مقایسه می‌کند sdkType=js را به همه‌ی لینک‌های صفحه اضافه کنید و origin پیام را با آدرسی که باز کرده‌اید مقایسه کنید
فروشگاه جایزه به محض باز شدن بسته می‌شود دست‌دادن token ورود حساب شده token را نادیده بگیرید؛ فقط login ورود است
ورود اول گیر می‌کند یا سرور 400 می‌دهد ورود پیش از اینکه بازیکن کاربر بازینس و توکن داشته باشد شروع شده اول /api/platform/token را صدا بزنید و صفحه را با همان توکن باز کنید
وارد شده ولی بازی هنوز مهمان نشان می‌دهد /api/platform/login صدا زده نشده یا پروفایل دوباره خوانده نشده با login صدایش بزنید و بعد پروفایل بازیکن را دوباره بخوانید
صفحه‌ی ورود «ادامه به عنوان مهمان» نشان می‌دهد allowGuest=false نیست روی صفحه‌های ورود، پروفایل و پیشنهاد اضافه‌اش کنید (نه /shop)
صفحه‌ی جایزه از بازیکن می‌خواهد وارد شود ریشه‌ی سایت به جای /shop باز شده {web}/shop?game_name=… را با linkedToken باز کنید
درخواست‌های پلتفرم بعد از مدتی شکست می‌خورند توکن کلاینت منقضی شده (401) توکن تازه بگیرید (قدم ۱) و یک بار دیگر بفرستید
توکنی که درست به نظر می‌رسد رد می‌شود validate-user-token پاسخ isValid: false داده (HTTP 200) فیلد isValid را بررسی کنید، نه فقط کد HTTP؛ کلاینت دوباره وارد شود
یک بازیکن همان سکه‌ها را دو بار گرفت transactionId از زمان ساخته شده آن را از ماهیت پرداخت بسازید (قدم ۵)
دریافت جایزه بعد از یک کرش برای همیشه شکست می‌خورد add-coin پاسخ 400 داده و دریافت آن را خطا حساب کرده 400 را «قبلاً پرداخت شده» حساب کنید
تب پرداخت سفید می‌ماند تب بعد از منتظر ماندن برای سرور یا با noopener باز شده داخل همان ضربه یک تب خالی باز کنید و بعد آدرسش را تنظیم کنید
بازیکن پرداخت کرد ولی چیزی نگرفت هیچ‌وقت برنگشت، پس بررسی اجرا نشد با فوکوس و «پرداخت کردم» بررسی کنید، و هنگام باز شدن فروشگاه پرداخت‌های در انتظار را بررسی کنید
verify_payment همیشه شکست می‌خورد به شکل JSON فرستاده شده بدنه‌ی فرم بفرستید
پرداخت پاسخ no_required_params می‌دهد uid عددی نیست شناسه‌ی عددی بازیکن را بفرستید
خرید دوم همان بسته مسدود است خرید consume نشده بعد از دادن آیتم و با چند بار تلاش consume را صدا بزنید
بازیکنانی که از اپ بازینس می‌آیند از آموزش رد می‌شوند ?linkedToken= نشانه‌ی «بازیکن قدیمی» گرفته شده «بازیکن جدید» را از داده‌های خودتان تشخیص دهید
کمپین بعد از ورود گم می‌شود برچسب‌ها فقط از آدرس خوانده شده‌اند و بازی از اول اجرا شده پیش از ورود آن‌ها را روی دستگاه ذخیره کنید
نام فارسی کمپین‌ها نیست مقدارهای UTM فقط به‌صورت ASCII نگه داشته می‌شوند از نام ASCII استفاده کنید (shahrivar_1405)
نمای ورود روی اسپلش دیده نمی‌شود نما زیر اسپلش است آن را بالای همه‌چیز بگذارید

کمک گرفتن

  • کلیدها، صفحه‌ی پیشنهاد بازی و SKUها: از تیم پلتفرم بازینس بپرسید.
  • برای دیدن همه‌چیز در کنار هم، کد بازی نمونه را بخوانید.
مرجع

مرجع API

همه‌ی endpointها، آدرس‌ها و پیام‌ها در یک صفحه: API پلتفرم، پرداخت بازینس، صفحه‌های بازینس و endpointهایی که سرور خودتان باید داشته باشد.

API پلتفرم

  • اصلی: https://platform.karizmastudio.com/api
  • توسعه: https://platform-dev.karizmastudios.org/api

توضیح قدم‌به‌قدم: API پلتفرم.

متد و مسیر احراز هویت بدنه پاسخ توضیح
POST /client/auth — { clientId, clientSecret } { clientId, token } توکن کلاینت
POST /client/create-user توکن کلاینت { deviceId, platform, store, linkedToken } { userId, token, isLinkedToAccount } platform: Web / Android / Ios / Editor؛ store: Direct / GooglePlay / AppStore / CafeBazaar / Myket
GET /client/get-user-token/{userId} توکن کلاینت — { userId, token, isLinkedToAccount } توکن کاربر تازه
GET /client/validate-user-token/{token} توکن کلاینت — { userId, isValid } نامعتبر ← 200 با isValid: false و userId: 0
POST /client/add-coin توکن کلاینت { userId, amount, transactionId, metaData } { resourceId, amount } (موجودی جدید) transactionId تکراری ← 400 { success: false, error: "add resource failed" }
GET /user/get-coin توکن کاربر — { resourceId, amount } کلاینت صدا می‌زند؛ همه‌ی originها مجازند

هر درخواستی که توکن کلاینت نداشته باشد یا توکنش منقضی شده باشد پاسخ 401 می‌گیرد.

صفحه‌های بازینس

  • اصلی: https://baziness.com
  • توسعه: https://platform-web-dev.karizmastudios.org

جزئیات: صفحه‌های بازینس.

صفحه مسیر لازم اختیاری
ورود / packageName، sdkType=js linkedToken، allowGuest=false، buildStore
پروفایل /profile packageName، sdkType=js linkedToken، redirect=profile، allowGuest=false
فروشگاه جایزه «انتخاب جایزه» /shop packageName، sdkType=js، linkedToken game_name، redirect=shop
پیشنهاد بازی /game-offer game_name، packageName، sdkType=js linkedToken، allowGuest=false

پیام‌های صفحه‌ها

{ "type": string, "data"?: string | { "token": string } }، به شکل متن JSON یا شیء.

type کار
login با توکن به سرور خودتان بفرستید ← بررسی ← وصل یا جابه‌جایی ← بستن
login بدون داده در همان جا وصل شد ← تازه کردن
token نادیده بگیرید (دست‌دادن بی‌صدا)
logout بستن، پاک کردن داده‌های محلی، شروع دوباره مثل بازدیدکننده‌ی جدید
close بستن نما

پرداخت بازینس

  • اصلی: https://pay.baziness.com
  • توسعه: https://pay-dev.karizmastudios.org

uid باید عددی باشد. جزئیات: پرداخت بازینس.

متد و مسیر قالب بدنه بدنه پاسخ
GET /buy query sku، uid، pname صفحه‌ی پرداخت
POST /get_payment JSON { uid, sku, pname } { uid, sku, token, time, success } یا { error: "payment_is_not_valid", success: false }
POST /verify_payment فرم uid، token، sku، pname { success, error }
POST /consume JSON { uid, sku, pname } { success }

endpointهای سرور شما (پیشنهادی)

Endpoint بدنه پاسخ
GET /api/platform/config — { enabled, webBase, apiBase, packageName, gameName, gameOfferUrl }
POST /api/platform/token { deviceId, linkedToken?, tag?, utm? } { enabled, …تنظیمات, token?, userId?, isLinkedToAccount?, switchToDeviceId?, error? }
POST /api/platform/login { deviceId, token } { ok, switchToDeviceId?, reason? }
GET /api/shop — محصولاتی که این بازیکن می‌تواند بخرد
POST /api/shop/purchase/start { productId } { paymentId, url }
POST /api/shop/purchase/verify { paymentId } { status, granted, balances }
POST /api/shop/purchase/cancel { paymentId } —
GET /api/shop/purchase/pending — [{ paymentId, productId }]

تنظیمات

تنظیم پیش‌فرض معنی
BAZINESS_PLATFORM_ENABLED false کلید اصلی حساب، سکه و صفحه‌ها
BAZINESS_PLATFORM_USE_PRODUCTION false آدرس‌های اصلی یا توسعه
BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET — کلیدها (محرمانه)
BAZINESS_PLATFORM_PACKAGE_NAME — packageName در لینک صفحه‌ها
BAZINESS_GAME_NAME — game_name در صفحه‌ی جایزه و پیشنهاد
BAZINESS_PLATFORM_API_URL / BAZINESS_PLATFORM_WEB_URL محاسبه‌شده آدرس جایگزین
BAZINESS_GAME_OFFER_URL {web}/game-offer?game_name=… آدرس جایگزین صفحه‌ی پیشنهاد
BAZINESS_PAY_ENABLED false کلید اصلی خرید
BAZINESS_PAY_BASE_URL — https://pay-dev.karizmastudios.org یا https://pay.baziness.com
BAZINESS_PAY_PACKAGE_NAME — pname
BAZINESS_SKU_PREFIX خالی SKU = پیشوند + شناسه‌ی محصول
DEV_FAKE_BAZINESS_PAY false درگاه آزمایشی محلی (هرگز در محیط اصلی)