feat: P2 — التسوية بالرسائل + إزالة مارتن + مستند البنية (docs/25)
P2 (كليك/شام كاش بلا API): - الرسالة تُحفظ خاماً **قبل** أي تحليل: التحليل قد يفشل فنحتاج الأصل لإعادة المعالجة، وعند النزاع يكون النصّ الأصلي هو الحجّة لا تفسيرُنا له. - النقطة تصنع المال، فرسالة مزوّرة = رصيد من عدم. الحماية: سرّ لكل مستأجر بمقارنة ثابتة الزمن (المقارنة النصّية تسرّب السرّ حرفاً حرفاً زمنياً)، وبصمة محتوى فريدة تمنع احتساب إعادة الإرسال مرتين. - Gemini بحرارة صفر ومطالَب بإرجاع null عند عدم اليقين: نموذج يخمّن مبلغاً يسوّي فاتورة بمال لم يصل. بلا مبلغ صريح → مراجعة بشرية لا تسوية. - المطابقة بالمرجع أولاً، ثم بالمبلغ خلال 24 ساعة وبشرط فاتورة وحيدة — فاتورتان بنفس المبلغ التباسٌ يُراجَع، لا تسويةٌ عشوائية لإحداهما. - التسوية تمرّ بـmarkSuccess نفسه فلا يتفرّع مسار مالي ثانٍ. إزالة حاوية martin: انطلق منصّة قائمة بذاتها لها خوادمها؛ دورنا طلب وردّ لا استضافة خرائط (قرار المالك). docs/25: جرد الحاويات · لماذا الدفع ليس خدمة منفصلة · سيرفر السوبر-أدمن المنفصل ونطاقه الفرعي · WebSocket مقابل الاستطلاع بالأرقام · أحجام السيرفرات على أساس الذروة لا المعدّل · نقل مستأجر · ترتيب التوسّع. 13 اختباراً جديداً (201 إجمالاً، كلها خضراء). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b1a060c5ed
commit
da035e46a4
@@ -84,7 +84,16 @@
|
||||
- `GET /admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر (نطاقه من التوكن).
|
||||
- `/admin/overview` يفصل `trip_commission` عن `revenue` الكلي.
|
||||
|
||||
**الباقي**: P1 (بوابات فعلية) · P2 (تسوية بالرسائل) · P4 (تقارير أوسع) · وسحب المالك أرباحه من `tenant_wallet`.
|
||||
**منجَز أيضاً (P2 — التسوية بالرسائل):**
|
||||
- `tripz_pay_raw_sms`: الرسالة تُحفظ **خاماً وقبل أي تحليل**، وفهرس فريد على بصمة المحتوى يمنع معالجة إعادة الإرسال مرتين.
|
||||
- `POST /payments/sms/webhook/:tenantSlug` — بلا JWT (الرافع جهاز)، محميّ بسرّ لكل مستأجر (`settings.payments.sms_webhook_secret`) بمقارنة **ثابتة الزمن**؛ ومستأجر بلا سرّ = النقطة **مغلقة** لا مفتوحة.
|
||||
- `GeminiService.extractTransferSms` بحرارة صفر وتعليمات صريحة بإرجاع `null` عند عدم اليقين — النموذج الذي يخمّن مبلغاً يسوّي فاتورة بمال لم يصل.
|
||||
- المطابقة: المرجع أولاً، ثم المبلغ ضمن 24 ساعة و**بشرط فاتورة وحيدة**؛ فاتورتان بنفس المبلغ = التباس يُراجَع بشرياً.
|
||||
- التسوية تمرّ بـ`settleFromSms` → `markSuccess` نفسه، فلا مسار مالي ثانٍ يتفرّع ويتناقض.
|
||||
- `GET /payments/sms/review` + `POST /payments/sms/review/:id/match` لطابور المراجعة والربط اليدوي.
|
||||
- 13 اختباراً تغطّي التزوير والتكرار والتخمين والالتباس.
|
||||
|
||||
**الباقي**: P1 (بوابات فعلية) · P4 (تقارير أوسع) · وسحب المالك أرباحه من `tenant_wallet`.
|
||||
|
||||
## 7. أثره على ما هو مبنيّ الآن
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 25 — بنية السيرفرات والحاويات (التنظيم الكامل)
|
||||
|
||||
> إجابات أسئلة المالك 2026-07-18: كم حاوية؟ هل الدفع منفصل؟ أين السوبر-أدمن؟ ما حجم السيرفر؟ كيف ننقل مستأجراً؟
|
||||
> ذو صلة: [14](14-server-conventions.md) · [15](15-deploy-flow.md) · [20](20-tls.md) · [24](24-tenant-wallet-revenue.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. الحاويات: أربع، والباك إند **واحد** لا مفكَّك
|
||||
|
||||
| الحاوية | الصورة | الدور |
|
||||
|---|---|---|
|
||||
| `tripz-api` | مبنيّة محلياً | كل الـHTTP + WebSocket |
|
||||
| `tripz-worker` | **نفس الصورة**، أمر مختلف | المهام المجدولة والطوابير (BullMQ) |
|
||||
| `tripz-postgres` | postgis/postgis:16 | القاعدة |
|
||||
| `tripz-redis` | redis:7-alpine | الكاش · المطابقة · المواقع · الطوابير |
|
||||
|
||||
**الدفع ليس حاوية منفصلة، وهذا مقصود.** المدفوعات وحدة (module) داخل نفس التطبيق تشارك القاعدة والمعاملة (transaction). فصلها إلى خدمة مستقلة يعني أن تسوية دفعةٍ تكتب في محفظتين عبر الشبكة بلا معاملة واحدة — أي انهيارٌ في المنتصف يترك مالاً نصف مسوّى. الوحدة الذرّية للمال هي معاملة قاعدة واحدة، ولا تُقطَع بحدود شبكة إلا لضرورة قاهرة. حين يكبر الحمل نُشغّل **نسخاً أكثر من نفس الـAPI**، لا خدمات مجزَّأة.
|
||||
|
||||
**`tripz-martin` أُزيلت** (قرار المالك): انطلق منصّة قائمة بذاتها لها خوادمها وبلاطاتها. دورنا طلبٌ وردّ — لا استضافة خرائط ولا قاعدة جغرافية ضخمة نصونها بلا مقابل.
|
||||
|
||||
## 2. أين يقع كل شيء
|
||||
|
||||
```
|
||||
┌──────────── سيرفر المنصّة (Control Plane) ────────────┐
|
||||
│ tripz-api (سوبر-أدمن فقط) · postgres · redis │
|
||||
تطبيقات ولوحات ──────┤ admin.tripz.com ← لوحة السوبر-أدمن │
|
||||
└───────────────────────┬──────────────────────────────┘
|
||||
│ HTTPS + سرّ المنصّة
|
||||
┌────────────────────────────────────┼────────────────────────────────┐
|
||||
│ │ │
|
||||
┌───────▼────────┐ ┌────────▼───────┐ ┌─────────▼──────┐
|
||||
│ سيرفر مشترك │ │ سيرفر سيادي │ │ سيرفر مستأجر │
|
||||
│ عدة مستأجرين │ │ مستأجر واحد │ │ آخر… │
|
||||
│ api·worker·db │ │ api·worker·db │ │ │
|
||||
└────────────────┘ └─────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
كل صندوق يشغّل **نفس الأربع حاويات**. الفرق في `.env` وحده: أي قاعدة، أي بادئة، أي مستأجرين.
|
||||
|
||||
## 3. سيرفر السوبر-أدمن المنفصل — كما طلبت
|
||||
|
||||
**لماذا منفصل**: سرّ المنصّة (`PLATFORM_SECRET`) يفتح كل المستأجرين. بقاؤه على صندوق يشاركه مستأجرٌ يعني أن اختراق مستأجر واحد = اختراق المنصّة كلها. الفصل يجعل نطاق أي اختراق مستأجراً واحداً.
|
||||
|
||||
**الإعداد**:
|
||||
1. صندوق جديد (4 vCPU · 8GB يكفي — لا يحمل رحلات).
|
||||
2. نفس `docker compose` بـ`.env` خاص: قاعدته الخاصة، و`PLATFORM_SECRET` **موجود هنا فقط**.
|
||||
3. نطاق فرعي على دومينك: `admin.tripz.com` → Nginx يُنهي TLS ويوجّه إلى `127.0.0.1:4010`.
|
||||
4. **يُحذف `PLATFORM_SECRET` من سيرفرات المستأجرين** — بلا هذا يبقى الفصل شكلياً.
|
||||
|
||||
**نقطة تحتاج قراراً**: السوبر-أدمن يقرأ اليوم من **قاعدته المحلية**. مع سيرفرات متعدّدة لن يرى أرقام المستأجرين البعيدين تلقائياً. خياران:
|
||||
- **(أ) دفع دوري**: كل سيرفر مستأجر يرسل ملخّصه (رحلات · GMV · إيراد) إلى المنصّة كل ساعة. بسيط، والأرقام متأخّرة ساعة — وهذا مقبول لأنها أرقام إدارية لا تشغيلية. **توصيتي.**
|
||||
- **(ب) سحب حيّ**: المنصّة تنادي كل سيرفر عند فتح اللوحة. أرقام لحظية، لكن سيرفراً متوقّفاً يُعطّل اللوحة كلها.
|
||||
|
||||
## 4. WebSocket بدل الاستطلاع — وأثره العددي
|
||||
|
||||
في سيرو كان السائق **يستطلع** (polling) بحثاً عن طلبات، فكل سائق يولّد طلباً كل بضع ثوانٍ سواء وُجد عمل أم لا. مع 1000 سائق واستطلاع كل 3 ثوانٍ = **333 طلب/ثانية دائمة بلا أي رحلة**.
|
||||
|
||||
عندنا: اتصال WebSocket واحد يبقى مفتوحاً، ولا تمرّ بيانات إلا عند **حدث فعلي**. نفس الألف سائق = صفر طلب في الهدوء. هذا الفرق وحده هو سبب اتّساع الطاقة.
|
||||
|
||||
**الغرف المبنيّة** (`realtime.gateway.ts`):
|
||||
- `tenant:{id}:user:{userId}` — إشعارات المستخدم.
|
||||
- `tenant:{id}:drivers` — بثّ لكل السائقين المتصلين.
|
||||
- `tenant:{id}:trip:{tripId}` — التتبّع والدردشة والمكالمة للطرفين.
|
||||
|
||||
**«الرحلات المتاحة» (طلبك)**: تُبنى فوق `tenant:{id}:drivers` القائمة، مع غرفة لكل منطقة (`tenant:{id}:zone:{zoneId}`) حتى لا يُبثّ عرضٌ في عمّان إلى سائق في حلب. **بند مفتوح — يُنفَّذ عند المجموعة التالية.**
|
||||
|
||||
**التوسّع الأفقي جاهز**: `redis-io.adapter` مبنيٌّ أصلاً، فنسخ الـAPI المتعدّدة تتشارك الغرف عبر Redis. سائقٌ متصل بالنسخة أ يستقبل حدثاً بثّته النسخة ب.
|
||||
|
||||
## 5. الطاقة والحجم — بالأرقام
|
||||
|
||||
اختبار الحمل (docs/22 §1.2) على **صندوق مشترك مزدحم أصلاً**: ≈ **1.15 مليون رحلة/يوم**، أي **115 ضعف** هدف الـ10 آلاف/يوم.
|
||||
|
||||
| الهدف اليومي | الحجم الكافي | ملاحظة |
|
||||
|---|---|---|
|
||||
| حتى 10 آلاف رحلة | 4 vCPU · 8GB | الوضع الحالي — فائض كبير |
|
||||
| حتى 100 ألف | 8 vCPU · 16GB | صندوق مخصّص لا مشترك |
|
||||
| حتى مليون | 8–16 vCPU · 32GB · NVMe | حدّ الصندوق الواحد |
|
||||
| فوق مليون | نسختا API + فصل القاعدة | التقسيم يبدأ هنا لا قبله |
|
||||
|
||||
**وقت الذروة هو المقياس لا المعدّل اليومي**: مليون رحلة/يوم ليست 11.5 رحلة/ثانية موزّعة بالتساوي؛ الذروة (7–9 صباحاً · 4–7 مساءً) تحمل ~٢٥٪ من اليوم في ساعتين، أي ≈ 35 رحلة/ثانية. الحجم يُقاس على هذا.
|
||||
|
||||
**العنق دائماً Postgres (بركة الاتصالات) لا الـCPU.** أول ما يبطؤ النظام، ارفع البركة وافحص الاستعلامات قبل أن تشتري معالجات. Redis 4–8GB يكفي لأن ما فيه نصوص صغيرة.
|
||||
|
||||
## 6. نقل مستأجر إلى سيرفر خاص
|
||||
|
||||
لأن كل شيء في حاويات وكل مستأجر معزول ببادئة، النقل ميكانيكي:
|
||||
|
||||
1. صندوق جديد + `git clone` + `.env` خاص به.
|
||||
2. `docker compose up -d --build` ثم `npm run migration:run`.
|
||||
3. تصدير بيانات المستأجر من القاعدة القديمة واستيرادها.
|
||||
4. `POST /admin/tenants/provision` أو نقل صفّه.
|
||||
5. توجيه نطاقه الفرعي إلى الصندوق الجديد.
|
||||
6. **تعليق المستأجر على الصندوق القديم** (`PATCH /admin/tenants/:id/status`) قبل التبديل — يمنع كتابةً جديدة على القاعدة القديمة أثناء النقل، وهي أخطر لحظة في العملية كلها.
|
||||
|
||||
## 7. التوسّع بالترتيب — لا تقفز خطوة
|
||||
|
||||
1. **رأسياً**: كبّر الصندوق. أرخص وأبسط ويكفي حتى ~مليون/يوم.
|
||||
2. **نسخ API**: `docker compose up -d --scale api=3` خلف Nginx. الكود بلا حالة والـRedis adapter جاهز.
|
||||
3. **فصل القاعدة**: Postgres على صندوق خاص + نسخة قراءة للتقارير.
|
||||
4. **فصل Redis**: نادراً ما يلزم.
|
||||
5. **التقسيم (sharding)**: لا داعي له في الأفق المنظور — بحث الحمل حسمها.
|
||||
|
||||
## 8. الاحتياطي
|
||||
|
||||
نسخة Postgres متدفّقة (streaming replica) + Redis AOF على صندوق ثانٍ. `RPO ≈ ثوانٍ`، `RTO ≈ دقائق` (وقت إقلاع compose).
|
||||
⚠️ نسخة احتياطية لم تُختبَر استعادتها ليست نسخة احتياطية. **بند مفتوح: تمرين استعادة دوري.**
|
||||
|
||||
---
|
||||
|
||||
## 9. بنود مفتوحة من هذا المستند
|
||||
- غرف «الرحلات المتاحة» حسب المنطقة (§4).
|
||||
- آلية تجميع أرقام المستأجرين للسوبر-أدمن — الخيار (أ) الموصى به (§3).
|
||||
- تمرين استعادة النسخة الاحتياطية (§8).
|
||||
- إزالة `PLATFORM_SECRET` من سيرفرات المستأجرين عند فصل المنصّة (§3).
|
||||
Reference in New Issue
Block a user