پلتفرم فروش VPN
فروشگاهی برای اشتراک VPN. مشتری پلن را در وبسایت یا داخل بات تلگرام انتخاب میکند؛ وبسایت او را برای پرداخت به زرینپال میفرستد و وقتی پرداخت تأیید شد، یک worker پسزمینه حسابش را روی پنل Marzban میسازد. اپراتور پلنها را از پنل مدیریت اداره میکند و میتواند کاربری را غیرفعال یا اشتراکی را لغو کند. یک بکاند FastAPI و پایگاه دادهاش همهٔ واقعیتها را نگه میدارند — چه کسی پرداخته، چه چیزی فعال شده، کِی تمام میشود: وبسایت و بات از طریق API با آن حرف میزنند، پنل مدیریت توسط همان سرو میشود، و دو worker روی همان پایگاه داده کار میکنند. خودِ VPN بخشی از پروژه نیست: ساختن حساب به Marzban و API آن سپرده شده است.
نتیجهها
-
353 تست خودکار که همه پاس میشوند روش اندازهگیری
- روش
- اجرای python -m pytest روی کل مجموعه: ۲۵ ماژول تست، ۳۵۳ تست جمعآوریشده، بدون skip
- تاریخ اندازهگیری
- نمونه
- مخزن در کامیت 185b9ab
- محیط
- ماشین محلی، Python 3.13.7، pytest 8.4.1، PostgreSQL 18
-
0 خطای lint و type روش اندازهگیری
- روش
- ruff check روی app/ bot/ tests/ alembic/؛ tsc --noEmit و eslint . داخل website/ (یک هشدار ESLint، بدون خطا)
- تاریخ اندازهگیری
- نمونه
- مخزن در کامیت 185b9ab
- محیط
- ruff 0.11.13، TypeScript 5.7.2، ESLint 9.17.0
یک خرید از چه میگذرد
یک خرید چهار شیء در بکاند است که به ترتیب ساخته میشوند و هر مرحله با کلیدِ خودش بدون تکرار است. purchase intent ثبت میکند که کاربری پلنی میخواهد، با کلیدی که کلاینت میفرستد. سفارش از روی intent ساخته میشود، و هر intent حداکثر یک سفارش میسازد. تلاش پرداخت با کلید دومی از کلاینت روی سفارش باز میشود؛ از سمت وبسایت به زرینپال سپرده میشود، که با یک authority جواب میدهد و تلاش پرداخت آن را نگه میدارد، و وقتی درگاه callback میزند، بکاند پرداخت را با مبلغی که خودش ذخیره کرده تأیید میکند و بعد سفارش را «پرداختشده» میکند. مسیر درخواست همینجا تمام میشود. worker فعالسازی سفارشِ پرداختشده را claim میکند، حساب را روی پنل Marzban میسازد، نام کاربری و لینک اشتراک را روی سطر اشتراکی که برای هر سفارش یکتاست ذخیره میکند و سفارش را «فعال» میکند. وبسایت هر یک از این حالتها را با سرکشی به یک endpoint وضعیت، از طریق route handler خودش، به مشتری نشان میدهد. بات همان intent، سفارش و تلاش پرداخت را از طریق همان سرویسها میسازد و همان وضعیت را سرکشی میکند؛ در این کامیت هنوز مشتری تلگرام را به درگاه نمیفرستد.

تضمینها کجا هستند
دو تا از پنج قاعدهای که پروژه برای خودش گذاشته، اینکه هیچ پرداختی دو بار پردازش نشود و هیچ سفارشی دو بار فعال نشود، با پایگاه داده و یک ماشین حالت تضمین میشوند، نه با خوشرفتاریِ فراخوانندهها.
- سفارش از نُه حالت میگذرد و پرداخت از چهار حالت، و تغییر حالت از یک تابع برای هر مدل عبور میکند که برای هر چیزی خارج از جدول صریحِ گذارهای مجاز خطا میدهد. تنها استثنا دو شاخهٔ بازیابی در همان ماژول است که وقتی اشتراکِ سفارش از قبل وجود دارد و فعال است، سفارش را مستقیم «فعال» میکنند.
- یکتایی دقیقاً همانجایی اعلام شده که تکراری میتوانست ساخته شود: یک intent برای هر کاربر و کلید idempotency، یک سفارش برای هر intent، یک پرداخت برای هر کلید idempotency، یک پرداخت برای هر authority درگاه، یک پرداخت برای هر مرجع درگاه، یک intent برای هر token، و یک اشتراک برای هر سفارش.
- اشتراک با دو کلید خارجی ترکیبی به سفارشش بسته شده، روی (سفارش، کاربر) و روی (سفارش، پلن)، پس یک سطر نمیتواند به سفارشی اشاره کند که مال مشتری یا پلن دیگری است.
- پول عددی صحیح به تومان است و واحد پول با یک check constraint ثابت شده. پرداختی که مبلغش با جمع سفارش برابر نباشد، هم پیش از شروع و هم دوباره پیش از تأیید رد میشود.
- سطر کاربر باید یا ایمیل بههمراه hash رمز داشته باشد یا شناسهٔ تلگرام، و هرگز رمز بدون ایمیل. این یک check constraint است، نه یک کامنت.
دو رابط مشتری، و سومی
وبسایت Next.js هیچوقت tokenای را در مرورگر نگه نمیدارد. هر دو token در کوکیهای HttpOnly هستند و هر تماسِ مرورگر با یک endpoint محافظتشده از یک route handler میگذرد که هدر bearer را اضافه میکند، در صورت ۴۰۱ refresh token یکبارمصرف را دقیقاً یک بار خرج میکند و دوباره تلاش میکند، و جفت چرخاندهشده را دوباره بهصورت کوکی مینویسد. صفحههای رندرشده در سرور فهرست عمومی پلنها و کاربر فعلی را مستقیم میخوانند و نشستِ منقضی را با هدایت به یک مسیر refresh جداگانه بازیابی میکنند. یک کوکی نگهبان نمیگذارد یک نشستِ خراب بین آن مسیر و صفحهٔ ورود رفتوبرگشت کند، و مقصد بعد از ورود پیش از دنبالشدن در برابر شکلهای protocol-relative بررسی میشود.
بات تلگرام، ساختهشده روی aiogram، در حالت polling و بهصورت تکنسخه اجرا میشود. با یک service token به بکاند احراز هویت میکند و شناسهٔ کاربر تلگرام را در header میفرستد. تنها وضعیتش یک رکورد جریانِ پانزدهدقیقهای است با یک token، شناسهٔ پلن، یک کلید idempotency تولیدشده و یک زمان؛ token جریانِ کهنه یا ناهمخوان پیش از هر تماس با API خرید توسط خودِ بات رد میشود، و ضربهٔ تکراری در بکاند به همان intent، همان سفارش و همان تلاش پرداخت میرسد.
پنل مدیریت با Jinja سمت سرور رندر میشود، روی همان API مدیریت و همان احراز هویت JWT، و token را فقط بهعنوان حامل در یک کوکی HttpOnly نگه میدارد. دامنهاش عمداً محدود است: خواندنهای عملیاتی، مدیریت پلنها، وضعیت کاربر و لغو اشتراک. اولین admin با یک ابزار خط فرمان ساخته میشود که یک token یکبارمصرف میخواهد و اگر adminای از قبل وجود داشته باشد، اجرا نمیشود.
اجرا
محیط تولید هفت پردازه است، API، بات، worker فعالسازی، worker انقضا، وبسایت، PostgreSQL و Redis، هر کدام کانتینر خودش از یک نمونهٔ compose تولید، و migration مرحلهای جدا و صریح. ایمیج پایتون با کاربر غیر root اجرا میشود. ماژول تنظیمات در محیط تولید با debug روشن یا با مقدار جاینگهداری که برای هر یک از secretهای خودش باقی مانده باشد، از بالا آمدن سر باز میزند (token یکبارمصرفِ ساخت admin جداگانه خوانده میشود و بررسی نمیشود)، و endpoint سلامت وقتی پایگاه داده در دسترس نیست به جای وانمود کردن، «degraded» را با ۵۰۳ گزارش میدهد.
چه چیزی ساخته نشده
سند دامنه فهرست خودش را دارد و صادق است: تیکت پشتیبانی، صفحهٔ تمدید برای مشتری (بکاند از الان هدف تمدید را میپذیرد)، تاریخچهٔ پرداخت، سمتِ وبسایتِ اتصال حساب تلگرام (پایههای بکاندش هست)، و تأیید دستی پرداخت توسط admin همه به بعد از این نسخه موکول شدهاند. یک مورد را هم کد نشان میدهد نه سند: بات سفارش و تلاش پرداخت را میسازد، اما سپردن مشتری به زرینپال فقط در وبسایت وجود دارد.
تصمیمها
فعالسازی در یک worker انجام میشود که سفارش را claim میکند، نه داخل درخواستِ پرداخت
وقتی زرینپال پرداخت را تأیید میکند، سفارش «پرداختشده» میشود. ساختن حساب روی پنل Marzban یک تماس شبکهای با سیستمی دیگر است که میتواند کند یا از دسترس خارج باشد، و باید دقیقاً یک بار برای هر سفارش انجام شود.
- گزینههای دیگر
- فعالسازی داخل callback پرداخت — سند استقرار همین را صریحاً منع میکند: برای فعالکردن سفارشهای پرداختشده به درخواستهای وب تکیه نکنید. پشت یک callback، درگاه و مشتری منتظرند، و از کار افتادن پنل یک پرداختِ موفق را به یک درخواستِ ناموفق تبدیل میکرد.
انتخاب: یک پردازهٔ جداگانه هر بار یک سفارشِ پرداختشده را با SELECT … FOR UPDATE SKIP LOCKED claim میکند، یک claim token مینویسد، commit میکند و فقط بعد از آن با پنل تماس میگیرد. هم در تکمیل و هم در شکست، پیش از دستزدن به سفارش بررسی میشود که آن token هنوز معتبر باشد.
- workerای که بعد از claim از بین برود، بازیابی میشود: claimای که از PROVISIONING_STALE_CLAIM_SECONDS (بهطور پیشفرض ۳۰۰ ثانیه) قدیمیتر باشد دوباره قابل گرفتن است، و timeout تماس با پنل الزاماً باید کوتاهتر از این پنجره باشد تا یک تماس معلق نتواند از claim خودش بیشتر عمر کند.
- دو worker میتوانند همزمان اجرا شوند. SKIP LOCKED نمیگذارد روی یک سطر بیفتند و claim token نمیگذارد workerای که claimاش کهنه شده، سفارشی را که دیگری برداشته تکمیل کند.
- شکستها با backoff نمایی از PROVISIONING_RETRY_BASE_SECONDS تکرار میشوند، با سقف PROVISIONING_RETRY_MAX_SECONDS، و آخرین خطا کنار زمان تلاش بعدی روی خودِ سفارش ذخیره میشود؛ هنوز چیزی در پنل مدیریت آن را نمیخواند.
محافظت در برابر تکرار در پایگاه داده است، نه در بات
کاربر تلگرام میتواند دو بار روی «پرداخت» بزند، بات میتواند وسط جریان ریاستارت شود، و یک callback میتواند بیش از یک بار تحویل داده شود. جایی باید یادش بماند که این خرید قبلاً وجود داشته است.
- گزینههای دیگر
- نگهداشتن وضعیت خرید در بات — README همین را با نام منع میکند: وضعیت بات دورریختنی است و نباید وضعیت سفارش، پرداخت، اشتراک یا چرخهٔ عمر را نگه دارد. هر چیزی که بات به خاطر میسپرد با ریاستارت از بین میرفت و ضربهٔ دوم، سفارش دوم میشد.
انتخاب: سه قید یکتایی، هر کدام برای یک مرحله: purchase intent روی (کاربر، کلید idempotency)، یک سفارش برای هر intent، و یک پرداخت برای هر کلید idempotency. بات فقط یک token جریان، شناسهٔ پلن، یک کلید تولیدشده و یک زمان را نگه میدارد، آن هم پانزده دقیقه.
- تکرار یک callback پرداخت، همان intent، همان سفارش و همان تلاش پرداخت را برمیگرداند. تکراری که همان کلید را با پلنی دیگر بفرستد بهعنوان تعارض رد میشود، نه اینکه بیصدا دوباره استفاده شود.
- وبسایت از همان سرویس خرید و همان قیدها میگذرد، بنابراین هیچکدام از دو کلاینت تعریف خودش را از «تکراری» ندارد.
- هر پرداخت به یک authority درگاه گره خورده که خودش یکتاست، و مبلغش هم پیش از شروع و هم پیش از تأیید با جمع سفارش مقایسه میشود.
انقضا اول حساب را روی پنل غیرفعال میکند و بعد سطر را، زیر یک قفل
worker انقضا اشتراکهای فعالی را پیدا میکند که تاریخشان گذشته، و باید دو سیستم را تغییر دهد، حساب Marzban و سطر پایگاه داده، بیآنکه هیچوقت آن دو را به نفع مشتری ناهمخوان رها کند.
- گزینههای دیگر
- اول غیرفعالکردن اشتراک در پایگاه داده — اگر بعدش تماسِ غیرفعالسازی شکست میخورد، پایگاه داده میگفت اشتراک تمام شده در حالی که حساب هنوز کار میکرد. docstring خودِ سرویس همین حالت را بهعنوان چیزی که برای جلوگیری از آن وجود دارد نام میبرد: تماسِ ناموفق با provider، پایگاه داده را غیرفعال رها کند در حالی که حساب Marzban هنوز فعال است.
انتخاب: یک سطرِ منقضیشده و هنوز فعال با SKIP LOCKED گرفته میشود، در حالی که قفل نگه داشته شده تماس غیرفعالسازی زده میشود، و فقط در صورت موفقیت سطر غیرفعال و سفارش «منقضی» میشود. تماسِ ناموفق بدون نوشتن برمیگردد، پس سطر فعال میماند و در دور بعد دوباره گرفته میشود.
- حالت شکست فقط در یک جهت ممکن است: ممکن است پنل پیش از آنکه سطر بگوید غیرفعال شده باشد، اما سطر هرگز در حالی که حساب هنوز کار میکند «منقضی» نمیشود.
- تکرار ذاتاً امن است، چون فقط سطرهای فعال گرفته میشوند.
- انقضا پردازهٔ خودش را دارد با بازهٔ سرکشی خودش، جدا از فعالسازی و جدا از API.
port فقط جایی که یک سیستم باید قابل تعویض باشد
بکاند با دو سیستم بیرونی که در اختیارش نیستند تماس میگیرد: پنل Marzban و درگاه زرینپال.
- گزینههای دیگر
- یک interface جلوی هر لایه — یادداشت معماری README خط را کشیده است: port فقط برای یکپارچهسازیهای بیرونی که باید قابل تعویض باشند. routeها، سرویسها و مدلها یک codebaseاند و از درزی بین خودشان چیزی به دست نمیآورند.
انتخاب: دو port انتزاعی، VPNProvider با provision و renew و disable و get، و PaymentProvider با initiate_payment و verify_payment، هر کدام با یک adapter. سرویسهای فعالسازی و انقضا provider را بهعنوان آرگومان میگیرند و هرگز Marzban را import نمیکنند.
- تستهای فعالسازی، انقضا و پرداخت برای هر دو port نسخهٔ ساختگی تزریق میکنند، پس کل مجموعه بدون دسترسی به هیچ پنل و هیچ درگاهی اجرا میشود.
- تعویض درگاه یعنی یک adapter؛ ماشینهای حالت بالای آن تغییر نمیکنند.
migration یک مرحلهٔ استقرار است، نه عارضهٔ جانبی راهاندازی
API و هر دو worker یک پایگاه دادهٔ PostgreSQL مشترک دارند و هر کدام با زمانبندی خودش ریاستارت میشود.
- گزینههای دیگر
- اجرای upgrade هنگام راهاندازی API — راهاندازی عمداً migration اجرا نمیکند. README اجرای upgrade را مرحلهای الزامی و صریح پیش از بالا آمدن کانتینرهای جدیدِ اپلیکیشن کرده است، در هر استقراری که تغییر schema دارد.
انتخاب: یک سرویس migrations در فایل compose تولید، پشت یک profile، که بین بالا آوردن پایگاه داده و بالا آوردن سرویسهای اپلیکیشن اجرا میشود. API بدون پایگاه داده هم بالا میآید و روی endpoint سلامتش «degraded» گزارش میدهد، به جای آنکه crash کند.
- استقرار با تغییر schema سه دستور با ترتیب ثابت است، و ریاستارت یک کانتینر هرگز نمیتواند schema را عوض کند.
- endpoint سلامت وقتی پایگاه داده در دسترس نیست با بدنهای جزءبهجزء ۵۰۳ برمیگرداند، تا orchestrator بتواند پردازهٔ مرده را از وابستگیِ غایب تشخیص دهد.