# 34 — معمارية تعدد المستأجرين: سيرفر مستقل لكل مستأجر > أُنشئ 2026-07-24. **حُدِّث في نفس اليوم بقرار المالك: سيرفر منفصل لكل مستأجر** (بدل قاعدة بيانات لكل مستأجر على سيرفر مشترك). > كل رقم هنا مقروء من الكود مباشرة. المستودع: `~/development/App/Siro`. > يُقرأ مع: `docs/21` (عيوب سيرو) · `docs/33` (الوحدات) · `docs/06` (نموذج المستأجر) · `docs/19` (الاستحقاقات). --- ## 0) القرار **نسخة كاملة مستقلة لكل مستأجر على سيرفره الخاص.** لا كود تعدد مستأجرين إطلاقاً — لا `tenant_id`، ولا `TenantContext`، ولا حتى تعديل على `Database::get()`. | البديل | الحكم | |---|---| | عمود `tenant_id` في 175+ جدول | ❌ مرفوض — أشهر عمل + خطر تسرّب دائم | | قاعدة لكل مستأجر على سيرفر مشترك | 🟡 كان اقتراحي الأول (~5 أسابيع كود) | | **سيرفر كامل لكل مستأجر** | ✅ **المعتمد — صفر كود تعدد مستأجرين** | ### لماذا هذا القرار صحيح فعلاً (وليس مجاملة) 1. **`docker/docker-compose.yml` موجود مسبقاً ومكتوب حرفياً لهذا الغرض** — التعليق في أول الملف: «سيرو في حاويات — **نسخة واحدة كاملة على سيرفر واحد**». البنية التي تحتاجها مبنية بالفعل. 2. **يُلغي كل كود تعدد المستأجرين** الذي قدّرته بـ5 أسابيع. تقديرك «يوم عمل لكل مستأجر» **واقعي — بشرط الأتمتة** (§3). 3. **العزل مطلق:** لا شرط `WHERE` منسي، لا بادئة Redis مشتركة، لا استعلام يخترق. المخاطرة الأمنية الأولى في أي SaaS تختفي كلياً. 4. **نصف قطر الانفجار = مستأجر واحد.** سيرفر يسقط أو يُهاجَم أو تمتلئ قاعدته → البقية لا يشعرون. 5. **يطابق وعد الموقع حرفياً:** «عزل بياناتك» لم يعد شعاراً تسويقياً بل حقيقة معمارية. **وباقة «سيادة» تصبح هي المعيار لا الاستثناء.** 6. **التصدير والنسخ الاحتياطي تافهان** — `mysqldump` واحد. أقوى نقطة بيع في العقد صارت أرخص ميزة عندك. ### التكلفة الحقيقية للقرار — وهي حقيقية **أنت لم تُلغِ التعقيد، بل نقلته من الكود إلى التشغيل.** بدل «كيف أعزل المستأجرين في كود واحد؟» صار السؤال **«كيف أدير 50 سيرفراً بلا فريق DevOps؟»** هذا سؤال أسهل وأوضح — لكنه **يقتلك إن لم تحلّه بالأتمتة من المستأجر الثاني**، لا من العاشر. باقي هذا المستند هو الجواب. --- ## 1) 🔴 تصحيح فوري: «سيرفرات رخيصة» غير دقيق جمعت حدود الذاكرة من `docker-compose.yml` الحالي: | الحاوية | الحد | |---|---| | mysql | 2,560 م.ب | | php-fpm | 2,048 | | socket_driver | 768 | | socket_passenger | 512 | | redis | 512 | | nginx | 256 | | phpmyadmin | 256 | | **المجموع** | **≈ 6.9 غ.ب** | **سيرفر بـ2 غ.ب لن يشغّل هذه النسخة كما هي.** الحل ليس شراء سيرفرات ضخمة، بل **ثلاث وصفات compose حسب حجم المستأجر:** | المقاس | الموارد | حدود مضبوطة | يناسب | |---|---|---|---| | **S** | 2 نواة / 4 غ.ب / 80 غ.ب SSD | mysql 1g · php 768m · redis 256m · بلا phpmyadmin | < 300 رحلة/يوم | | **M** | 4 نواة / 8 غ.ب / 160 غ.ب | الحدود الحالية | < 3,000 رحلة/يوم | | **L** | 8 نواة / 16 غ.ب + قرص منفصل لـMySQL | موسّعة + `pricing-engine` | > 3,000 رحلة/يوم | **الوحدات تغيّر المقاس:** `pricing-engine` (Node) و`marketing_engine` يرفعان الحاجة درجة كاملة. اربط مقاس السيرفر بالباقة والوحدات في نفس عرض السعر. **التكلفة التقديرية:** 50 مستأجراً على مزيج S/M ≈ **$700–1,200/شهر** مقابل إيراد $10k–35k/شهر. الهامش سليم تماماً — لكن **احسبها في التسعير من الآن**، لا لاحقاً. > 🔴 **`phpmyadmin` يُحذف من وصفة الإنتاج.** لوحة قاعدة بيانات مكشوفة على 50 سيرفر = 50 باب خلفي. للصيانة: نفق SSH عند الحاجة فقط. --- ## 2) تقسيم الملفات: مشترك ↔ خاص بالمستأجر سألت: هل نقدر نفصل المشترك عن الخاص بالمستأجر؟ **نعم — وهذا بالضبط الانضباط الوحيد الذي يجعل 50 سيرفراً قابلة للإدارة.** ``` /srv/tripz/ ├── app/ ← من git. مشترك. ❌ لا يُعدَّل على السيرفر أبداً │ ├── backend/ payment_server/ loction_server/ docker/ ├── config/ ← خاص بالمستأجر. ليس في git إطلاقاً │ ├── .env (DB · Redis · JWT · APP_DOMAIN · GLOBAL_COUNTRY) │ ├── tenant.json (slug · اسم العرض · الباقة · الوحدات المفعّلة) │ └── secrets/ (PayMob · Gemini · SMS · Firebase · مفاتيح JWT) ├── branding/ ← خاص: شعار · ألوان · اسم التطبيق · نصوص └── storage/ ← خاص: uploads · logs · backups ``` ### القاعدة الحديدية > **إن عدّلت يوماً ملفاً داخل `app/` على سيرفر مستأجر — فقد صنعت فرعاً (fork)، والتحديث القادم إما يمسحه أو يفشل بتعارض.** > أي سلوك خاص بمستأجر يُقاد من `config/` أو من أعلام الميزات — **لا بتعديل الكود. أبداً.** هذه الجملة هي الفرق بين 50 سيرفراً تُحدَّث بأمر واحد، و50 فرعاً يستحيل صيانتها. اكتبها على الحائط. الوضع اليوم جيد: `.gitignore` يستثني `.env` و`*.pem` و`service-account.json` بشكل صحيح. **الناقص:** فصل `config/` و`branding/` كمجلدات مستقلة خارج شجرة الكود. --- ## 3) التزويد: من «يوم عمل» إلى «أمر واحد» **المستأجر الأول يدوياً — لكن وأنت تكتب كل خطوة.** ملاحظاتك هي السكربت. من المستأجر الثاني: السكربت فقط. ```bash provision-tenant.sh --slug=acme --host=api.acme.com \ --size=M --plan=brand --country=EG \ --modules=ai,pricing_intel ``` ما يفعله بالترتيب: 1. إنشاء السيرفر عبر API المزوّد (Hetzner/DO) بالمقاس المطلوب 2. تقوية أساسية: مستخدم غير جذري · SSH بمفتاح فقط · جدار ناري (22/80/443 فقط) · fail2ban · تحديثات أمنية تلقائية 3. `git clone --branch <آخر وسم مستقر>` إلى `app/` 4. توليد `config/.env` بكلمات سرّ عشوائية + `tenant.json` 5. `docker compose up -d` بوصفة المقاس 6. تشغيل الهجرات + بذرة البيانات + `schema_version` 7. Let's Encrypt للنطاق 8. تركيب الكرونات (حسب الوحدات المفعّلة فقط) 9. تسجيل النسخ الاحتياطي اليومي 10. **تسجيل المستأجر في السجل المركزي** + توليد مفتاح التخاطر (§5) 11. **فحص دخان:** تسجيل دخول → عرض سعر → طلب رحلة وهمية → تأكيد وصول أول تخاطر **الخطوة 11 غير قابلة للتفاوض.** «تم النشر» بلا فحص دخان يعني أن أول من يكتشف العطل هو العميل. > 🔴 **مصيدة كلمات السرّ:** لا تستنسخ نفس `JWT_SECRET` أو كلمة سرّ MySQL عبر المستأجرين. اختراق واحد = سقوط الجميع. السكربت **يولّد** لكل مستأجر أسراره. --- ## 4) التحديثات عبر Git — النموذج الآمن فكرتك صحيحة (git كمصدر واحد)، لكن `git pull` الساذج على 50 سيرفراً كارثة تنتظر. الفروق الأربعة التي تحوّلها لنظام: ### 4.1 وسوم لا فروع **لا تنشر `main` أبداً.** انشر وسماً: `git fetch --tags && git checkout v1.4.2`. بلا هذا لا تعرف ما الذي يعمل عند أي مستأجر، ولا تستطيع التراجع. ### 4.2 نشر على حلقات (Rings) ``` الحلقة 0: سيرو (سيرفرك أنت) ← 24 ساعة مراقبة الحلقة 1: 3 مستأجرين متعاونين ← 24 ساعة الحلقة 2: الباقون ← دفعات من 10 ``` **لا تنشر على الخمسين دفعة واحدة.** يوم واحد من الصبر يوفّر ليلة انهيار كامل. ### 4.3 هجرات + `schema_version` كل سيرفر يسجّل نسخة مخططه. المنشِّط يرفض التشغيل إن كان الفارق أكثر من نسخة، ويوقف عند أول فشل بدل المتابعة. **انجراف المخططات هو القاتل رقم واحد في هذا النموذج** — لأن كل سيرفر عنده MySQL خاص به. ### 4.4 تراجع جاهز قبل النشر لا بعده `rollback-tenant.sh --slug=acme --to=v1.4.1` — يشمل هجرة عكسية. **إن لم يكن التراجع مجرَّباً فهو غير موجود.** ### 4.5 المنسّق Ansible مع ملف جرد المستأجرين، أو سكربت bash + ssh حلقي إن أردت البقاء بسيطاً. الجوهر: **أمر واحد على جهازك ينفّذ على مجموعة محددة من السيرفرات ويعيد تقرير نجاح/فشل لكل واحد.** > 🟠 **`deploy.sh` الحالي (`git add . && git push origin --all`) لا يصلح لهذا النموذج** — يدفع كل الفروع ويضيف كل شيء بلا تمييز. يُستبدل بمسار إصدار: فرع ميزة → مراجعة → دمج في main → وسم → نشر بالحلقات. --- ## 5) لوحة السوبر-أدمن والتخاطر المركزي فكرتك: كل مستأجر يرسل بياناته لنقطة نهاية مركزية كل ساعتين. **الاتجاه صحيح تماماً** — دفع من المستأجر إلينا لا سحب: لا نحتاج منفذاً مفتوحاً في سيرفراتهم، يعمل خلف أي جدار ناري، وأنت لا تملك وصولاً دائماً لبياناتهم (وهذا **يقوّي** وعد الخصوصية على الموقع بدل أن يناقضه). ### 5.1 🔴 القاعدة التي تحمي الشركة: تجميعات فقط، لا بيانات شخصية ```jsonc // ✅ يُرسَل { "slug":"acme", "period_start":"2026-07-24T10:00:00Z", "period_hours":2, "trips":{"completed":412,"cancelled":37}, "gmv":{"amount":8420.50,"currency":"EGP"}, "drivers":{"active":88,"online_peak":61}, "passengers":{"new":24}, "health":{"version":"v1.4.2","schema_version":37,"uptime_s":864000, "errors_5xx":3,"db_size_mb":2140,"disk_free_pct":62}, "modules":["ai","pricing_intel"] } // ❌ لا يُرسَل أبداً // أسماء · هواتف · إحداثيات · مسارات رحلات · صفوف مستخدمين · أي PII ``` سحب البيانات الخام لسيرفرك المركزي **يهدم وعد «بياناتك تبقى لك» حرفياً**، ويضعك تحت طائلة قوانين حماية بيانات في كل دولة يعمل فيها مستأجروك. **تجميعات فقط. بلا استثناء.** ### 5.2 عقد نقطة النهاية | البند | القرار | |---|---| | المسار | `POST /telemetry/v1/report` | | المصادقة | مفتاح لكل مستأجر + **توقيع HMAC** على الجسم + طابع زمني (حماية من إعادة الإرسال) | | مفتاح التكرار | `(slug, period_start)` — إعادة الإرسال لا تُضاعف الحساب | | التخزين | **append-only** للحمولة الخام + جداول مجمّعة مشتقة منها | | فشل المركز | المستأجر **يصطفّ محلياً** ويعيد الإرسال لاحقاً — وإلا خسرت بيانات فوترة | | فشل المستأجر | تنبيه بعد فوات نافذتين متتاليتين | ### 5.3 🟠 ساعتان بطيئة جداً للأعطال الساعتان ممتازتان للفوترة والتحليل. **لكن انقطاع مستأجر يجب أن تعرفه خلال دقيقة لا ساعتين.** ➡️ **نبضة خفيفة كل 60 ثانية** (بضع بايتات: أنا حيّ + النسخة) + **الحمولة الكاملة كل ساعتين**. النبضة تكشف السقوط، والحمولة تروي القصة. ### 5.4 🔴 لا تفوتر من رقم يملكه العميل المستأجر يملك سيرفره → يستطيع تعديل ما يرسله. **لا تبنِ فاتورة نسبة GMV على تخاطر غير موثوق وحده.** الخيارات: ① مطابقة مع سجلات بوابة الدفع (المصدر المستقل الوحيد) · ② توقيع سجلات الرحلات بمفتاح لا يملكه المستأجر · ③ قبول المخاطرة صراحةً لباقة انطلاقة، والمطابقة للباقات الكبرى. **اختر واحداً بوعي — لا تتركه للصدفة.** ### 5.5 الوصول للدعم التخاطر يخبرك **أن** هناك مشكلة، لا **ما** هي. للتشخيص تحتاج مساراً استثنائياً: مفتاح SSH دعم تحتفظ به + **بند صريح في العقد** يمنحك حق الدخول للصيانة، ويفضَّل أن يكون مؤقتاً ومسجّلاً. **لا تدخل سيرفر عميل بلا غطاء تعاقدي مكتوب.** --- ## 6) لوحات الأدمن وخدمة العملاء فحصت `siro_admin` و`siro_service`: **كلاهما تطبيق Flutter وفيهما مجلد `web/`** — أي أن `flutter build web` يخرج صفحة ويب من نفس الكود مباشرة. **جوابك: نعم، ومن نفس المصدر بالضبط، بلا إعادة كتابة.** المعمارية الصحيحة — **لوحتان منفصلتان تماماً، ولا تخلطهما أبداً:** | | **لوحة المستأجر** | **لوحة السوبر-أدمن** | |---|---|---| | المصدر | `siro_admin` + `siro_service` (Flutter Web) | تطبيق مركزي جديد | | أين تعمل | على سيرفر المستأجر نفسه | سيرفرك المركزي | | ماذا ترى | بيانات هذا المستأجر فقط | تجميعات كل المستأجرين | | العنوان | `admin.acme.com` · `support.acme.com` | `console.tripz.com` | | العلامة | علامة المستأجر من `branding/` | علامتك | > 🔴 **لا تبنِ لوحة واحدة تسجّل الدخول إلى كل سيرفرات المستأجرين.** ستحمل حينها مفاتيح دخول لكل عملائك في مكان واحد — وتُعيد بناء كل مخاطر تعدد المستأجرين التي تخلّصت منها للتوّ. السوبر-أدمن يقرأ من **قاعدة التخاطر المركزية فقط**. **العمل المطلوب:** تعمية العلامة في `siro_admin`/`siro_service` (الاسم والشعار والألوان تُقرأ من `branding/` وقت البناء أو التشغيل) + بوابة استحقاقات تخفي الوحدات غير المشتراة (docs/19). --- ## 7) خطة العمل بالترتيب | # | المهمة | المدة | البوابة | |---|---|---|---| | 1 | فصل `app/ ↔ config/ ↔ branding/ ↔ storage/` | 3 أيام | 🔴 كل ما بعده يعتمد عليها | | 2 | ثلاث وصفات compose (S/M/L) + حذف phpmyadmin | 2 أيام | | | 3 | **إصلاح عيوب المال في `docs/21`** | 5 أيام | 🔴 **قبل أول مستأجر مدفوع** | | 4 | `provision-tenant.sh` + فحص الدخان | 4 أيام | | | 5 | مسار الإصدار: وسوم + حلقات + `schema_version` + تراجع | 4 أيام | | | 6 | التخاطر: مرسِل عند المستأجر + نقطة نهاية + نبضة 60ث | 4 أيام | | | 7 | لوحة السوبر-أدمن (تقرأ التخاطر) | 5 أيام | | | 8 | تعمية العلامة في admin/service + بوابة الاستحقاقات | 4 أيام | | | | **المجموع** | **~5 أسابيع** | | **لماذا ما زالت 5 أسابيع رغم أن كود تعدد المستأجرين اختفى؟** لأن العمل انتقل — من كتابة عزل في الكود، إلى بناء أدوات أسطول. **الفارق الحاسم: هذه الأدوات تُبنى مرة وتخدم كل مستأجر إلى الأبد، أما عزل الكود فكان سيبقى خطراً دائماً في كل استعلام جديد.** الصفقة رابحة. **وضع الحد الأدنى (لأول 3 مستأجرين):** البنود 1 · 3 · 4 فقط ≈ **12 يوماً**. الباقي يُبنى بينما تبيع — لكن **لا تتجاوز الخامس** بلا البند 5 (التراجع)، ولا **العاشر** بلا البند 6 (التخاطر). --- ## 7.5) مدة التسليم وإيقاع التحديثات — مقارنة بالسوق ### ما يعد به المنافسون فعلياً (مقروء من مواقعهم 2026-07-24) | المنصة | الوعد المعلن | |---|---| | **Onde** — الصفحة التسويقية | «أطلق خلال **14 يوماً**» | | **Onde** — صفحة الأسئلة الشائعة | «إطلاق المنصة يستغرق **حوالي 4 أسابيع**» | | متوسط القطاع (مزوّدو white-label) | **2 – 8 أسابيع** | **الملاحظة الأهم:** Onde نفسها **تعد بـ14 يوماً في الإعلان و4 أسابيع في الأسئلة الشائعة.** هذا ليس تناقضاً عشوائياً — الرقم التسويقي يقيس *جاهزية المنصة*، والرقم الواقعي يشمل **مراجعة المتاجر** التي لا يملك أحد التحكم بها. **وعدنا (30 يوماً) في الموضع الصحيح تماماً:** أصدق من «14 يوماً» وأسرع من سقف القطاع. **لا تخفّضه** — تأخير أسبوع على وعد 14 يوماً يحرق الثقة، بينما التسليم في 20 يوماً على وعد 30 يوماً يصنع بطلاً. > **صياغة موصى بها في العقد:** «المنصة جاهزة وتعمل خلال 10 أيام عمل · النشر على المتجرين خلال 30 يوماً، وهو خاضع لمراجعة آبل وجوجل.» تفصل ما تتحكم به عمّا لا تتحكم به. ### إيقاع تحديثات المنافسين Onde تقول: «تحديثات منتظمة لكلا النظامين بلا رسوم إضافية» — **بلا جدول معلن ولا ذكر لتحديث هوائي.** أي أن **التحديث الهوائي (Shorebird) تمايز حقيقي لك، لا لحاق بالركب.** --- ## 7.6) التحديث الهوائي (Shorebird) — الفرصة والمصيدة ### القاعدة التقنية الحاكمة **Shorebird يرقّع كود Dart فقط.** أي تغيير في الطبقة الأصلية — Kotlin/Swift · إضافات (plugins) · أذونات · ترقية محرك Flutter — **يستوجب إصداراً كاملاً في المتجر.** هذا يتطابق مع القرار المثبّت في `docs/22 §1.5`: **الطبقة الأصلية تبقى موحّدة عبر كل المستأجرين والمستويات** — وهذا بالضبط ما يجعل الترقيع الهوائي ممكناً أصلاً. **أي انحراف في الطبقة الأصلية بين مستأجر وآخر يقتل الميزة كلها.** ### 🔴 المصيدة: الرقعة تتضاعف بعدد المستأجرين كل مستأجر = تطبيقان بهويتَي حزمة خاصتين = **تطبيقان مستقلان في Shorebird**. 50 مستأجراً = **100 تطبيق × كل إصلاح**. الترقيع اليدوي مستحيل عند هذا الحجم — ولا تكتشف ذلك عند المستأجر الأربعين. **اللازم:** `patch-all.sh` يمرّ على جرد المستأجرين، ويرقّع بنفس الحلقات المعتمدة في §4.2 (سيرو أولاً → متعاونون → الباقون). **ونفس قاعدة التراجع: رقعة قابلة للسحب، مجرَّبة قبل الاعتماد عليها.** **وتنبيه تكلفة:** تسعير Shorebird يقوم على التطبيقات وعمليات تثبيت الرقع — تكلفتك تنمو خطياً مع عدد المستأجرين. **احسبها ضمن الكلفة التشغيلية لكل مستأجر من الآن**، مع مقاسات السيرفرات في §1. ### 🟠 حدّ آبل Shorebird مصمَّم للامتثال، لكن بند **App Store 3.3.2** يبقى منطقة الخطر: استعمل الرقع **لإصلاح الأعطال وتعديل السلوك**، لا لشحن ميزات تغيّر الغرض الأساسي للتطبيق. **الميزات الكبيرة تمرّ عبر المتجر.** انضباط بسيط يحميك من رفض جماعي يطال كل مستأجريك دفعة واحدة. ### الإيقاع المقترح | النوع | القناة | التكرار | |---|---|---| | إصلاح عاجل (Dart) | رقعة Shorebird | خلال ساعات | | تحسينات دورية (Dart) | رقعة | كل أسبوعين | | ميزة كبيرة أو تغيير أصلي | إصدار متجر | كل 6–8 أسابيع | | الباك إند | git بالحلقات (§4) | مستقل تماماً عن التطبيقات | **الرسالة البيعية:** «إصلاح عندك خلال ساعات، لا خلال أسبوعين انتظاراً لمراجعة المتجر.» — **لا أحد في القطاع يقدر يقولها.** --- ## 8) القرارات المطلوبة منك 1. **المزوّد**: Hetzner (الأرخص بفارق كبير، أوروبا) · DigitalOcean · مزوّد محلي في كل دولة؟ *(للسيادة في مصر/السعودية قد يفرض العقد مزوّداً محلياً — احسبها الآن)* 2. ~~النطاقات~~ ✅ **محسوم (المالك 2026-07-24): نطاق فرعي لكل مستأجر تحت نطاقنا** — `acme.tripz.app` · `api.acme.tripz.app` · `admin.acme.tripz.app`. **لا IP في التطبيقات إطلاقاً.** الفوائد: شهادة wildcard واحدة (`*.tripz.app`) بدل 50 شهادة منفصلة تنتهي في 50 موعداً · إنشاء المستأجر = سجل DNS واحد بلا انتظار العميل · نقل السيرفر = تغيير سجل A بلا تحديث متجر. **يبقى مسموحاً** للمستأجر الكبير أن يوجّه `api.acme.com` بـCNAME إلى نطاقه الفرعي لاحقاً (تُعالج بشهادة منفصلة) — لكن الافتراضي هو نطاقنا. 3. **الفوترة**: تُبنى على التخاطر وحده أم بمطابقة بوابة الدفع؟ (§5.4) 4. **حق دخول الدعم**: بند تعاقدي دائم أم إذن مؤقت لكل حادثة؟ 5. **مستودع Tripz أم مستودع سيرو؟** هل يصبح كود سيرو هو `backend/` في مستودع Tripz، أم يبقى مستودعاً مستقلاً ويُسحب كـsubmodule؟ *(توصيتي: مستودع واحد باسم Tripz — مصدر حقيقة واحد للنشر)* --- ## سجل التغييرات | التاريخ | التغيير | |---|---| | 2026-07-24 | إنشاء الدراسة (DB لكل مستأجر) | | 2026-07-24 | **تحديث جذري بقرار المالك: سيرفر كامل لكل مستأجر.** أُضيف: مقاسات السيرفرات، تقسيم الملفات، مسار الإصدار بالحلقات، عقد التخاطر، فصل لوحة المستأجر عن السوبر-أدمن |