پرش به محتویات

معماری

شکل کلی

  تلگرام            بله            روبیکا
      │               │               │
      ▼               ▼               ▼
 ┌─────────────────────────────────────────┐
 │  adapters/     ترجمهٔ بار                │   نه دیتابیس، نه قاعده
 └───────────────────┬─────────────────────┘
                     ▼
 ┌─────────────────────────────────────────┐
 │  bot/          صفحه‌ها و مسیریابی         │   نام هیچ پیام‌رسانی نیست
 └───────────────────┬─────────────────────┘
                     │
 ┌───────────────────┼─────────────────────┐
 │  api/  ───────────┤                     │   تجزیه، فراخوان، ترجمهٔ خطا
 └───────────────────┼─────────────────────┘
                     ▼
 ┌─────────────────────────────────────────┐
 │  modules/*/service.py                   │   هر قاعده، دقیقاً یک بار
 └───────────────────┬─────────────────────┘
                     ▼
 ┌─────────────────────────────────────────┐
 │  modules/*/models.py  ·  shared/        │
 └───────────────────┬─────────────────────┘
                     ▼
        PostgreSQL   ·   Redis   ·   Alembic

دو کلاینت — ربات و API — روی یک مجموعه سرویس برنامه می‌نشینند. کل طراحی در یک جمله همین است، و هر چیزی که پایین‌تر می‌آید نتیجهٔ آن است.

چرا درزها همان‌جایی‌اند که هستند

آداپتورها ترجمه می‌کنند و بس

MessagingAdapter یک پروتکل است: بار وارد، IncomingEvent خارج؛ OutgoingMessage وارد، فراخوان سرویس‌دهنده خارج. آداپتور نه نشستی دارد، نه سرویسی، و نه هیچ تصوری از اینکه دسترسی چیست.

این خواسته نمی‌شود، اعمال می‌شود. test_the_adapter_never_reaches_the_database هر ماژول داخل adapters/ را تجزیه می‌کند و اگر یکی SQLAlchemy یا سرویسی وارد کند شکست می‌خورد. گراف واردات را می‌پیماید نه متن را، پس توضیحی که نام مدلی را ببرد ساخت را نمی‌شکند و واردات واقعی نمی‌تواند داخل یک رشته پنهان شود.

سودش قابل‌اندازه‌گیری بود: افزودن بله چهار خط پیکربندی برد، چون API بله همان API ربات تلگرام است با میزبانی دیگر، و آن گویش یک بار در adapters/botapi.py زندگی می‌کند.

لایهٔ گفتگو نام هیچ پیام‌رسانی را نمی‌برد

bot/conversation.py فقط چیزی را نگه می‌دارد که مال هیچ بخشی نیست: savepoint دور هر به‌روزرسانی، کسانی که هنوز وصل نشده‌اند، فرمان‌ها، و دو توزیع‌کننده. هر کاری که آدم می‌کند در bot/features/ است، یک ماژول برای هر حوزه. هر بخش پیشوندهای کال‌بک و جریان‌های پیش‌نویسی را که مال اوست اعلام می‌کند و یک Context (سرویس‌ها و صندوق خروجی همان به‌روزرسانی) می‌گیرد، نه خود گفتگو را. اگر دو بخش یک پیشوند یا جریان را بخواهند، همان موقع import شکست می‌خورد.

bot/screens/ رندر می‌کند و به همین شکل تقسیم شده است: - یک ماژول برای هر بخش: account، books، payroll، planning، reports، transactions، wallet؛ - common برای آنچه چند بخش مشترک دارند: rtl()، قالب‌بندی پول، صفحه‌های خانه، خطا، پیام کهنه و انصراف، و آن واژگان ناوبری که صفحه‌کلید هر بخش از آن ساخته می‌شود (back_row()، cancel_row()، rows_of()).

bot/screens/__init__.py همهٔ نام‌ها را صریحاً دوباره export می‌کند، پس فراخوان‌ها همچنان screens.X می‌نویسند. ساختار ستاره‌ای است: صفحه‌های هر بخش فقط common را import می‌کنند و common هیچ‌کدام از آن‌ها را.

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

صفحه‌ها توابع خالص‌اند — داده وارد، متن و دکمه خارج — پس هر کدامشان بدون توکن، بدون شبکه و بدون دیتابیس تست می‌شوند. صفحه هیچ session، سرویس، دیتابیس، گفتگو یا adapterی جز Button را import نمی‌کند. اگر بکند، tests/test_screens_architecture.py ساخت را می‌شکند. همین تست این‌ها را هم نگه می‌دارد: - نام‌هایی که بسته قول داده، ثابت‌شده در یک فهرست؛ - یک تعریف برای هر نام؛ - payload کال‌بک حداکثر ۶۴ بایت؛ - متن فارسی از راه rtl()؛ - هیچ خطای انگلیسی‌ای که به دست آدم برسد.

پیام‌رسان به‌شکل آرگومان سازنده می‌رسد، برگرفته از adapter.provider. قبلاً در اجراکننده روی Provider.TELEGRAM سفت‌کد شده بود، یعنی به‌روزرسانی بله به هر حساب تلگرامی می‌رسید که همان شناسهٔ عددی را داشت. تستی که همان حلقه را روی بله اجرا می‌کند همان است که پیدایش کرد.

هر قدمی که commit می‌کند همان قدمی است که پیش‌نویس را برمی‌دارد

جریان چندقدمی ربات پیش‌نویسی را در انبار وضعیت نگه می‌دارد و در یک قدم ثبتش می‌کند. در حالت وب‌هوک هر به‌روزرسانی session خودش را دارد و دو تایشان هم‌زمان اجرا می‌شوند، پس دو نسخه از همان یک قدم می‌توانند با هم برسند — دو زدن، یا یک خط که دو بار فرستاده شده. نگهبانش این است که قدمِ ثبت‌کننده پیش از ثبت، پیش‌نویس را به‌شکل اتمیک از انبار بیرون می‌کشد: یک نسخه پیش‌نویسی پیدا می‌کند و آن یکی چیزی پیدا نمی‌کند و به او گفته می‌شود پیامش قدیمی است.

آن ثابتی که مهم است خود پیوند است، نه سازوکار: ادعایی که یک قدم عقب‌تر از commit بنشیند از چیزی محافظت نمی‌کند، پس وقتی commit جابه‌جا می‌شود ادعا هم با آن جابه‌جا می‌شود. جریان ثبت همین را در هر دو جهت تجربه کرده — commit از پرسش توضیح روی پیام مبلغ رفت و برگشت. هیچ‌چیز در سیستم نوعی این را نمی‌گوید، پس اینجا و در طراحی گفتگو گفته می‌شود، و تست‌هایی که دو به‌روزرسانی یکسان را مسابقه می‌دهند ادعایش می‌کنند.

قاعده‌ها در سرویس‌ها زندگی می‌کنند، یک بار

اگر قاعده‌ای در یک مسیر بود، ربات اطاعتش نمی‌کرد. اگر در صفحه‌ای بود، API نمی‌کرد. پس books.require(book_id, user_id, permission) تنها دروازه است، و هر دو کلاینت از آن رد می‌شوند.

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

پول همه‌جا Decimal است و روی مرز رشته

shared/money.py نوع Money را تعریف می‌کند، یک TypeDecorator که روی پستگرس Numeric(28, 4) است و روی SQLite String(40). هیچ چیزی هرگز اعشاری ممیز شناور نیست.

روی مرز HTTP پول به‌شکل رشته سریال می‌شود، چون تنها نوع عددی JSON اعشاری ممیز شناور است و 12500000.15 از آن رفت‌وبرگشت جان سالم به در نمی‌برد.

تقویم جلالی است، انبار نیست

تاریخ‌ها ستون date معمولی‌اند. دوره‌ای مثل 1403-05 پیش از اجرای هر کوئری در shared/jalali.py به بازهٔ میلادی [start, end] تبدیل می‌شود. دیتابیس هرگز مقدار جلالی نمی‌بیند، و کاربر هرگز میلادی.

مدل هویت

حساب کسب‌بوک یک User با UUID است. حساب تلگرام یک Identity است که به آن اشاره می‌کند.

   User (uuid)
     ├── Identity(telegram, 555001)
     ├── Identity(bale, 900123)
     └── Identity(web)

هیچ شناسهٔ پیام‌رسانی هرگز خودِ حساب نیست. آدم می‌تواند حساب تلگرامش را از دست بدهد، شمارهٔ تلفنش را عوض کند، یا به بله برود، و دفترهایش با آن‌ها نمی‌روند. قید یکتای (provider, external_id) همان است که جلوی تغذیهٔ دو مجموعه دفتر از یک حساب پیام‌رسان را می‌گیرد.

دفتر روزنامه

هر تراکنش یک سطر Transaction و یک JournalEntry متوازن با دست‌کم دو JournalLine می‌نویسد. تابع trial_balance() بدهکار و بستانکار یک دفتر را جمع می‌زند و تست‌ها ادعا می‌کنند که جور در می‌آیند — پس از ثبت، پس از حذف، پس از تسویهٔ بدهی، پس از پرداخت قسط وام.

ثبت دوطرفه را ببین.

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

پروسه یونیت چیست
ربات kasbbook-bot یک پیام‌رسان را می‌پاید، به‌روزرسانی‌ها را رسیدگی می‌کند، حلقهٔ یادآور را کنار پولر اجرا می‌کند
API kasbbook-api uvicorn، دو کارگر، بسته به لوپ‌بک
PostgreSQL کانتینر عمداً از نمونهٔ خود سرور جداست
Redis کانتینر وضعیت گفتگو و محدودیت نرخ

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

نقشهٔ پوشه‌ها

src/kasbbook/
  adapters/      base.py (پروتکل)، botapi.py (گویش مشترک)،
                 telegram.py، bale.py، rubika.py
  bot/           conversation.py، features/ و screens/ (یک ماژول برای هر حوزه)، quick.py، state.py
  api/           app.py، deps.py، schemas.py، errors.py، ratelimit.py، routers/
  modules/       identity، books، ledger، budgets، debts، loans، recurring،
                 reports، reminders، treasury، payroll
  shared/        money، jalali، parsing، settings، database، errors، security
  models.py      دفتری که تولید خودکار Alembic می‌خواندش

apps/bot/        runner.py (نقطهٔ ورود)، reminders.py
migrations/      محیط alembic، مقایسه‌گرها، versions/
deploy/          docker-compose.yml، دو یونیت systemd
scripts/         نصب، به‌روزرسانی، پشتیبان، بازگردانی، حذف، lib
tests/           کل مجموعه

انتخاب‌هایی که عمداً نشده‌اند

  • بدون بارگذاری تنبل ORM روی مرز ناهمگام. رابطه‌هایی که فراخوان لازمشان دارد lazy="selectin" هستند؛ فیشی که در حافظه ساخته می‌شود صریحاً payments=[] می‌گذارد به‌جای راه‌انداختن بارگذاری‌ای که هیچ‌کس نمی‌تواند منتظرش بماند.
  • بدون کامیت در سرویس. فراخوان صاحب تراکنش است — با دقیقاً یک استثنا، که همان‌جا مستند شده: تشخیص سرقت توکن تازه‌سازی، ابطالش را پیش از پرتاب خطا کامیت می‌کند، چون فراخوان هنگام شکست به عقب برمی‌گردد و ابطال باید از شکستی که خودش باعثش می‌شود جان سالم به در ببرد.
  • بدون select * داخل دیکشنری. سرویس‌ها مدل یا دیتاکلاس‌های کوچک برمی‌گردانند، پس ستونِ نام‌عوض‌کرده یک واردات مردود است، نه کلیدی که در زمان اجرا غایب می‌شود.