Files
tripz-llc/docs/17-backend-backlog.md
T

51 KiB
Raw Blame History

17 — Backlog الباك إند (مراجعة المالك + مقارنة سيرو)

مصدره: مراجعة المالك (2026-07-17) + قراءة مباشرة لجداول سيرو (schema_primary.sql, schema_ride.sql). الترتيب حسب الأثر. نمشي مجموعة مجموعة.


المجموعة A — الزمن الحقيقي والإشعارات (الأعلى أولوية) — ✅ منفَّذة

شكوى المالك الأساسية: «تسأل القاعدة كثيراً، أقرب للـ polling»، والإشعارات ناقصة.

# البند التفصيل الحالة
A1 FCM على كل حالات الرحلة حالياً push عند assigned فقط. المطلوب: FCM + WebSocket على كل انتقال. ضروري للخلفية (background). ✅ notifyParties تُنادى من كل انتقال + priority: high + حذف التوكنات الميتة
A2 ترجمة الإشعارات الإشعارات تُرسل إنجليزي والهاتف عربي → نصوص الإشعارات في ملفات ترجمة وتُرسل حسب لغة المستخدم. ✅ common/i18n (ar/en) + عمود users.language
A3 تقليل استعلامات القاعدة حالة الرحلة الجارية + المواقع في Redis؛ القاعدة للحقيقة الدائمة فقط. (الآن كل انتقال يقرأ/يكتب عدة مرات + يقرأ السائق ثانيةً). ✅ TripStateService (hash لكل رحلة نشطة، TTL 6س) — الانتقال صار UPDATE شرطي + قراءة واحدة
A4 Race condition عند القبول سائقان يقبلان بنفس اللحظة → قبول ذرّي (Redis SETNX / UPDATE شرطي WHERE status='searching'). أول قبول يفوز، والثاني يُرفض بوضوح. ✅ CAS بـLua في Redis + UPDATE … WHERE status='searching' كحَكَم نهائي
A5 إلغاء العرض عند القبول فور القبول: بث WebSocket + FCM لبقية السائقين المعروض عليهم → «الرحلة لم تعد متاحة» فتختفي من شاشتهم/الـ overlay. ✅ مجموعة عروض في Redis + trip:offer_taken + FCM
A6 الرحلات المتاحة (available rides) قائمة طلبات متاحة يسحبها السائق (بديل/مكمّل للعرض المباشر). ✅ GET /trips/available
A7 overlay أندرويد معلومات الرحلة للقبول/الرفض فوق التطبيقات — يحتاج FCM data-message + payload كامل. ✅ dataOnly + payload كامل (نقاط، مسافة، أجرة، فئة، دفع)

مؤجَّل من A: سجل الأحداث (trip_events) ما زال يُكتب متزامناً داخل الطلب — نقله إلى BullMQ يبقى تحسيناً مفتوحاً (worker.ts لا يزال هيكلاً فارغاً).


المجموعة B — اكتمال نموذج الرحلة — 🔵 الشريحة الأولى منفَّذة (المال والزمن)

مقارنة بجدول ride في سيرو + قواعد التشغيل.

# البند التفصيل الحالة
B1 حالة started وفصل الوصول عن البدء — ✅ كان منفَّذاً أصلاً: driver_arrived وin_progress حالتان منفصلتان في آلة الحالة منذ P1. الناقص كان الطوابع والانتظار (B2/B7) لا الحالة نفسها.
B2 عدّاد انتظار 5 دقائق (قانون) يبدأ عند driver_arrived، ويُحتسب عند in_progress. الدقائق المجانية free_waiting_min (5 افتراضاً) قابلة للضبط لكل تعرفة، والزائد بسعر per_min_waiting للنافذة الفعّالة. ✅ waiting_min + waiting_charge
B3 احتساب مشوار الوصول للراكب يُقاس من موقع السائق الحيّ لحظة القبول حتى نقطة الالتقاط. 🟡 القياس ✅ والقاعدة ❌ — راجع التصحيح أدناه
B6 فصل السعر price_for_passenger · price_for_driver · commission_amount · commission_rate. العمولة تُعرَّف في التعرفة: commission: { percent, flat, min }، ولا تتجاوز الأجرة أبداً. ✅ + تسوية المحفظة صُحّحت: كانت تُضيف للسائق كامل أجرة الراكب — أي أن العمولة كانت تضيع
B7 طوابع زمنية دقيقة driver_going_at (DriverIsGoingToPassenger) · arrived_at · started_at (rideTimeStart) · completed_at (rideTimeFinish). ✅ + كشف «الإنهاء السريع» صار يقيس من بدء الرحلة لا إسنادها
B4 نقاط توقف (stops) ✅ stops jsonb؛ المسار والتسعير يمرّان بالنقاط بالترتيب (أصل → توقف₁ → … → وجهة) بجمع أرجل المسار.
B5 حجز مسبق/جدولة ✅ scheduled_at + حالة scheduled (لا تدخل Redis ولا تُوزَّع). ScheduledTripsSweeper داخل الـAPI (يملك الـgateway) يطلقها قبيل موعدها بـUPDATE شرطي (آمن عبر النسخ).
B8 مطابقة الوجهة 🟡 الحقول والخصم منفَّذان: is_destination_match + destination_match_discount + destination_match_discount_pct في التعرفة، يُطبَّق في التسوية (الخصم على الراكب، السائق يقبض المخفَّض كاملاً). مؤجَّل: محرّك المطابقة الذي يضبط العلم (وضع «وجهة السائق») — يحتاج تخزين وجهة السائق + مطابقة اتجاهية في محرّك التوزيع.
B9 تعديل التعرفة من لوحة الأدمن جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). ⏳
B10 كتالوج أنواع الرحلات العالمي ride_types فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). ⏳
B11 تعرفة بالوزن بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. ⏳

🔴 تصحيحان لازمان على الشريحة الأولى (توضيح المالك 2026-07-17)

بنيتُ B3 وB6 على افتراضات خاطئة. الصواب:

1. مشوار الوصول ليس بنداً في كل أجرة — بل تعويض عن عدم حضور الراكب.

«مشوار الوصول يُحسب إذا انتظر السائق خمس دقائق وأُلغيت الرحلة بعد الخمس دقائق.»

فالقاعدة: يُحتسب فقط عند الإلغاء من حالة driver_arrived بعد انقضاء الدقائق المجانية. بنيتُه كمفتاح تعرفة يُضاف لكل رحلة (pickup_leg.charge) — خطأ. المطلوب: يحلّ محلّ رسم الإلغاء الثابت في تلك الحالة، ويذهب للسائق تعويضاً.

2. العمولة لا تُقتطع من أجرة السائق إطلاقاً — راجع 18-driver-credit-commission. price_for_driver = price_for_passenger (السائق يقبض كاملاً)، والعمولة تُخصم من رصيد تشغيلي مدفوع سلفاً. ما نُفِّذ (price_for_driver = fare − commission) نموذجُ أوبر، لا نموذجنا.

الأجرة النهائية بعد التصحيح = السعر المقفول + رسم الانتظار الزائد. ومشوار الوصول يظهر عند الإلغاء فقط.


المجموعة C — بيانات السائق والمركبة (من سيرو) — ✅ منفَّذة

# البند الحالة
C1 CarRegistration كامل ✅ جدول vehicles (مكافئ CarRegistration) — plate/vin (مشفَّران) · make/model/year · color+color_hex · fuel/owner/registration_expiry/category · is_default · status. سائق قد يملك أكثر من مركبة؛ VehiclesService يضمن افتراضية واحدة دائماً (أول مركبة تلقائياً، حذف الافتراضية يرقّي غيرها).
C2 حقول السائق ✅ على drivers: gender · national_number (مشفَّر + فهرس أعمى فريد لكل مستأجر، نمط الهاتف) · name_arabic (مشفَّر) · birthdate · address · license_type/categories/issue/expiry · rejected_reason. عبر PATCH /drivers/profile.
C3 ai_data + user_input ✅ عمودان jsonb على drivers وvehicles — مخرجات Gemini الخام مقابل ما أدخله السائق (يُراكَم لا يُستبدَل، للمقارنة حقلاً بحقل).
C4 صور السيارة ×2 ✅ vehicle_photo بـmin: 2 في كتالوج الوثائق (REQUIRED_DOCS).
C5 فيديو/liveness للوجه ✅ نوع وثيقة face_liveness جديد — الرفع يقبل الفيديو (لا قيد mime)، ومفصول عن مسار مطابقة Gemini للصور.

قرار تصميم: المركبة كيان مستقل (vehicles) لا حقول مسطّحة على السائق — سيرو نفسه يفصلها (CarRegistration مع isDefault)، وسائق واحد قد يبدّل مركبته. حقول vehicle_make/model/plate/color القديمة على drivers تبقى للتوافق؛ المصدر الجديد هو vehicles. مؤجَّل بوعي: مطابقة الوجه الآلية على face_liveness (استخراج إطار + Gemini vision) — البنية جاهزة، والمطابقة الفعلية عند تفعيل مزوّد الرؤية. accountBank/employmentType/maritalStatus من سيرو تُضاف عند الحاجة (ليست حرجة للتشغيل).


المجموعة D — الأمان والمصادقة — ✅ منفَّذة

# البند الحالة
D1 تطبيع أرقام الهاتف (JO/EG/SY) ✅ common/phone/phone.service.ts. مفتاح قانوني واحد لكل رقم حقيقي — يقبل صفراً محلياً/دولياً/+/00/بلا صفر، ويرجع دائماً صيغة دولية بلا +. يُطبَّق في send-otp/verify-otp/platform/users/role. هجرة NormalizePhones تُصحّح حسابات الاختبار الموجودة على السيرفر (بحذر: تتخطّى أي صفٍّ يتصادم ناتجه مع صفٍّ آخر بدل كسر القيد الفريد).
D2 بصمة الجهاز 🟡 مبنيّة ومطفأة (AUTH_REQUIRE_DEVICE_BINDING=false) حتى يرسل فلاتر x-device-id. مُنفَّذة داخل JwtStrategy نفسها لا كحارس منفصل — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل hash(deviceId) لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم.
D3 HMAC للعمليات الحساسة ✅ منفَّذ ضمن I6 — SigningService/SignatureGuard، نفس البند لا تكرار.
D4 حدّ الطلبات (rate limiting) ✅ إصلاح ثغرة قائمة — راجع أدناه.
D5 OTP متعدد المزوّدين حسب الدولة ✅ — راجع أدناه.

D5 — توجيه OTP حسب الدولة + failover (من سيرو auth/otp/)

مراجعة سيرو أظهرت: مصر تستعمل Kazumi SMS (sms.kazumi.me) مع failover لواتساب، وسوريا/الأردن Nabeh (واتساب، توكن مُخزَّن 24س). عندنا كان مزوّد واحد (Nabeh) فقط.

  • integrations/otp/ — واجهة OtpProvider + OtpDispatcher يوجّه حسب tenant.countryPack بسلسلة failover (eg: [kazumi, nabeh] · jo/sy: [nabeh]). إضافة دولة/مزوّد = صنف موفّر + سطر في السلسلة.
  • كل مزوّد يرسل رمزاً نولّده نحن (مخزَّن في Redis) — لا يولّده المزوّد. استبعدنا نمط Intaleq (يولّد الرمز ويرجعه) حفاظاً على مصدر واحد.
  • موحَّد لكل المسارات: تسجيل الدخول (AuthService) والسحب (PayoutsService) يمرّان بنفس المُوزِّع الآن — لا استدعاء Nabeh مباشر في أي مكان.
  • قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً. المصادقة = هاتف + OTP مرة → جلسة مربوطة بالجهاز (D2). لا حقل password، لا «نسيت كلمة المرور»، لا عبء دعم. سيرو نفسه لا كلمة مرور حقيقية له (password = hash(email) وهمي). يطابق أوبر/كريم/inDrive — المعيار في هذه الأسواق. التسجيل بالهاتف لا Google/Apple (محظوران في بعض الدول).
  • تصحيح لسيرو: رمزه من 3 خانات (1000 احتمال — ضعيف). عندنا 4 (قابل للرفع عبر OTP_LENGTH)، وعدّاد 5 محاولات (D4) يغلق التخمين.

D4 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً

ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]) كان مسجَّلاً في app.module.ts منذ البداية — لكن بلا أي حارس يطبّقه. لا APP_GUARD، ولا @UseGuards(ThrottlerGuard) في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها verify-otp وpayouts/*، كانت بلا أي حدّ طلبات إطلاقاً — التسجيل وحده لا يفعل شيئاً في NestJS.

  • ✅ APP_GUARD → ThrottlerGuard في app.module.ts — يُفعِّل الحدّ العام (120/دقيقة) على كل نقطة تلقائياً.
  • ✅ عدّاد محاولات لكل (مستأجر، رقم) في AuthService.verifyOtp — الحماية الحقيقية ضد تخمين الرمز، لا الحدّ العام: رمز 4 خانات = 10000 احتمال، ومهاجم يدوّر عناوين IP يتجاوز أي حدّ بالـIP وحده. 5 محاولات ثم إبطال الرمز، بنفس نمط payouts.service (I4).
  • ✅ حدود أضيق لكل نقطة على الأهداف عالية القيمة: send-otp (3/5د — كل إرسال يكلّف رسالة واتساب مدفوعة فعلياً عبر نبيه)، verify-otp (10/5د)، payouts/request (5/5د)، payouts/confirm (10/5د).
  • ✅ HealthController مُستثنى (@SkipThrottle()) — مراقبة التشغيل بلا بيانات حساسة.
  • 🟡 قرار وعي بالمخاطرة: فكّرت في تتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لتطبيق موبايل — عناوين NAT عند مشغّلي الجوّال تجمع آلاف المستخدمين خلف IP واحد). التنفيذ يحتاج حارساً مخصَّصاً بحقن يدوي دقيق (InjectThrottlerOptions/InjectThrottlerStorage)، وخطأ فيه يمنع إقلاع التطبيق كاملاً — ولا بيئة هنا لتشغيل NestFactory.create() والتحقق قبل الدفع (اختبارات jest تُنشئ الخدمات يدوياً، فلا تكشف أخطاء DI للحرّاس العالميين). رجّحت الأمان: تُرك بالتتبّع الافتراضي (IP)، والفكرة موثّقة هنا لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر قبل الدفع.

مقارنة بسيرو (core/Auth/RateLimiter.php): سيرو يتتبّع بـIP:userId (النمط الذي أجّلناه أعلاه) وله حدود مسمّاة لكل نوع (login 5/د · otp 3/5د · register 3/ساعة · api 120/د)، وأهم ميزة فيه: fallback بملف مؤقّت عند تعطّل Redis بدل تمرير كل الطلبات (fail-closed). عندنا مسار OTP fail-closed أصلاً: تخزين الرمز والعدّاد عبر Redis مباشرةً، فتعطّل Redis يرمي ويُفشل الطلب لا يمرّره. لكن حارس الطلبات العام (ThrottlerGuard) يستعمل ذاكرة داخلية لكل نسخة — لا يشارك بين النسخ. بند مؤجَّل: تخزين Throttler على Redis + fallback fail-closed عند الحاجة للتوسّع الأفقي الفعلي.


المجموعة E — كشف الاحتيال (من driver_ride_scam) — ✅ منفَّذة

# البند الحالة
E1 تسجيل زر الاتصال ✅ driver_called_passenger + last_call_at/last_call_by/call_count على الرحلة نفسها (لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة). يُسجَّل عند call:offer في RealtimeGateway — لحظة بدء الاتصال الفعلية، لا answer/ice/end (إشارات تفاوض لاحقة لنفس المكالمة).
E2 ربط الاتصال بالإلغاء ✅ FraudService.recordCallThenCancel — عدّاد يومي منفصل عن عدّاد الإلغاء العام (ذاك بالساعة ويعدّ كل إلغاء؛ هذا باليوم ويعدّ فقط الإلغاء المسبوق باتصال خلال 30 دقيقة). 3/يوم = إنذار (لا يمنع الإلغاء) — 6/يوم = حظر مؤقت، طبق طلب المالك حرفياً. يُنادى من TripsService.cancel بقراءة خفيفة لعمود واحد قبل التحديث.

المجموعة F — الدردشة

| F1 | إشعار الرسالة يُرسل فقط إذا لم تكن صفحة الدردشة مفتوحة (المعالجة الأساسية في فلاتر؛ الباك إند يرسل دائماً ويترك القرار للعميل أو عبر presence). |


المجموعة G — Redis خط أول والقاعدة خط احتياط — ✅ منفَّذة

مراجعة المالك (2026-07-17، الجولة الثانية): «كل ما بدي أبعث notification أستعلم من القاعدة — هذا ثقيل. الريدز خط أول، القاعدة نقطة الاحتياط». القاعدة: البيانات الساخنة والمتكررة تُقرأ من Redis؛ القاعدة تُقرأ مرة واحدة عند البرود (cache miss) ثم تُكتب في Redis. الأساس: common/cache/cache.service.ts — كاش-جانبي مع مبدأ فشل Redis لا يُسقط الطلب (يُسجَّل ويُرجَع للقاعدة).

# البند التفصيل الحالة
G1 لغة المستخدم في Redis فلاتر يفحص لغة الجهاز عند الفتح ويرفعها عبر PATCH /users/me → تُكتب في Redis مباشرة → الإشعار يقرأ من هناك. ✅ user:lang:{tenant}:{user} (TTL يوم) + كتابة عند التحديث
G2 توكنات الأجهزة (FCM) في Redis كان استعلام device_tokens قبل كل إرسال. ✅ user:fcm:{tenant}:{user}؛ يُبطَل عند register وعند اكتشاف توكن ميت
G3 البث الجماعي بلا استعلام لكل مستخدم — ✅ التوكنات واللغة من الكاش، فالبث الجماعي صار بلا استعلامات قاعدة أصلاً
G4 tenant في Redis كاش resolve (slug↔UUID) + config مع إبطال عند الإنشاء/التعديل. ✅ tenant:{slugOrId} (TTL ساعة)
G5 التعرفة و ride-types في Redis كاش مع إبطال صريح عند إنشاء تعرفة/نوع جديد. ✅ tariff:{t}:{city}:{class} و ridetypes:{t} (TTL 15د)
G6 التقييم بتراكم يومي مجموع/عدد في Redis، والقاعدة تُكتب مرة واحدة يومياً لكل طرف (حجز الكتابة ذرّي بـLua). التأخير مقصود ليبعد الاحتكاك. للطرفين معاً. ✅ + أُضيف ratings.target_user_id وusers.rating — تقييم السائق للراكب كان يُخزَّن بلا هدف فلا يُجمَّع أبداً
— رحلة معلّقة للتقييم عند الفتح GET /trips/rating/pending — يرجع أحدث رحلة منتهية لم يقيّمها المستخدم (سائقاً أو راكباً) + العدد. التطبيق يناديها عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة (طلب المالك). ✅
— كاش الخرائط route/reverse/geocode في Redis بإحداثيات مقرَّبة 4 خانات (~11م). ✅ TTL يوم. لا يُخزَّن الرجوع لخط مستقيم ولا استجابة فاشلة
G7 سعة Redis رصد مساحة أكبر أو Redis منفصل عند الحاجة (خصوصاً dispatch + المواقع). حالياً DB 3 مشترك — راجع docs/14. ⏳ قرار تشغيلي — يُراجَع بعد المجموعة H

تصحيح لادّعاء سابق في هذا المستند: كُتب أن TenantsService.resolve يعمل على كل request وأنه «أعلى نسبة قراءات في النظام». هذا خطأ — الـmiddleware يضع نص الهيدر في السياق فقط، وtenant_id يأتي جاهزاً من داخل الـJWT. resolve يُستدعى عند الدخول و3 كنترولرات فقط. كاشه مفيد لكن أثره أصغر بكثير مما ادّعيت. الحمل الحقيقي الأثقل هو رفع موقع السائق (استعلام + كتابة قاعدة كل نبضة) — وهو المجموعة H.


المجموعة H — المواقع والتتبع (من loction_server في سيرو) — ✅ منفَّذة

ملاحظة المالك: «لحد الآن مش شايف السائق أو الراكب يرفع موقعه، ولا جداول location». كان عندنا: driver:location عبر WebSocket → Redis GEO + حفظ صف السائق في Postgres على كل نبضة (drivers.updateLocation يعمل repo.save) — أثقل حتى من سيرو.

ما يفعله سيرو (مقروء من الكود):

  • driver_socket.php — سوكيت مخصص للمواقع، لا يلمس القاعدة إطلاقاً؛ Redis فقط عبر pipeline كل 500ms (REDIS_BATCH_INTERVAL).
  • عتبات لتقليل الكتابة: MIN_MOVE_METERS=10 (GEOADD فقط عند تحرّك >10م)، HMSET_SPEED_DELTA=1.0، HMSET_HEADING_DELTA=5، FORWARD_MIN_METERS=15 / FORWARD_MAX_SECONDS=3 للبث للراكب.
  • فهرسان منفصلان: geo:drivers:available وgeo:drivers:busy (عندنا فهرس واحد لكل فئة خدمة، بلا تمييز مشغول/متاح).
  • driver:profile:{id} hash في Redis (heading/speed/status) — المطابقة تقرأ منه بلا قاعدة.
  • عروض الرحلة: setex للعرض + sadd لمجموعة المعروض عليهم + expire — نفس نمطنا في A5 ✅.
# البند التفصيل الحالة
H1 إيقاف كتابة الموقع على القاعدة لكل نبضة drivers.updateLocation كان يكتب Postgres كل ثانية/ثلاث لكل سائق متصل. ✅ Redis فقط. الكتابة الدائمة صارت من الـworker
H2 عتبات + batching عتبات سيرو: 10م حركة · 1.0 سرعة · 5° اتجاه. سائق واقف = EXPIRE فقط (بلا كتابة ولا نقطة مسار ولا بثّ). ✅ + البثّ للراكب صار عند الحركة ذات الدلالة فقط
H3 لقطة «آخر موقع» لكل سائق صف واحد لكل سائق يكتبه الـworker دورياً. قرار: لم نُنشئ جدولاً منفصلاً — drivers أصلاً صف واحد لكل سائق؛ أُضيفت heading/speed/loc_status/loc_updated_at بجانب last_lat/last_lng. ✅
H4 driver_tracks (المسار) نقاط تاريخية للتتبع/النزاعات — تتراكم في قائمة Redis ويُدرجها الـworker بعبارة واحدة. ✅ جدول tripz_driver_tracks
H5 فهرس available/busy geo:drivers:{tenant}:{class}:{available|busy} — البحث يمسح المتاحين فقط. السائق يصير busy عند القبول ويعود available عند الإنهاء/الإلغاء. ✅
H8 ربط المواقع بالمطابقة المطابقة والاحتيال و«الرحلات المتاحة» صاروا يقرؤون الموقع الحيّ من Redis. ✅
H6 driver_behavior max_speed · avg_speed · hard_brakes · total_distance · behavior_score لكل رحلة. ⏳ مؤجَّل — تحليلات فوق المسار، تُبنى من driver_tracks لاحقاً
H7 driver_daily_work / driver_daily_summary ساعات عمل السائق (total_seconds باليوم + last_point_at + last_status). ⏳ مؤجَّل
— Geofence مؤجَّل بقرار المالك («خليها لوقتها») — موجود في سيرو (get_location_area_links, LocationIntelligenceEngine). ⏳

الـworker صار حقيقياً: كان هيكلاً فارغاً (setInterval بلا عمل). الآن NestFactory.createApplicationContext(WorkerModule) — قاعدة + Redis + المواقع، يفرّغ كل 5s (LOC_FLUSH_INTERVAL_MS)، بقفل يمنع تراكب الدورات، وتفريغة أخيرة عند SIGTERM حتى لا تضيع اللقطات عند النشر.

الأثر: سائق متصل كان يكلّف استعلاماً + كتابة قاعدة لكل نبضة (كل 1–3 ثوانٍ). الآن: نبضة السائق الواقف = أمر EXPIRE واحد، والمتحرّك = كتابة Redis + كتابة قاعدة واحدة كل 5 ثوانٍ مهما بلغ عدد نبضاته.


المجموعة I — المدفوعات والمحفظة (من payment_server في سيرو) 🔴 أمني

طلب المالك: تدقيق أمني على المدفوعات، خصوصاً الـpayout: OTP عبر نبيه + بصمة (وجه/إصبع) في فلاتر + HMAC.

بنية سيرو (مقروءة من WalletDB.sql + sms_webhook/):

  • جدول لكل طريقة دفع (كما قال المالك): cliq_invoices · ecash_transactions (+_driver) · invoices_shamcash (+_passenger) · mtn_invoices · invoices_sms (+_passenger) · kazan.
  • محافظ منفصلة: driverWallet · passengerWallet · siroWallet (محفظة الشركة) — نمط دفتر قيود append-only، الرصيد = SUM(amount).
  • نمط ذكي جداً: raw_sms_log + process_with_gemini.php — رسالة SMS من مزوّد الدفع تُرفع خاماً، Gemini يقرأها ويستخرج المبلغ/المرجع، ثم finalize_wallet_payment. يحلّ غياب الـAPI الرسمي في سوريا.
  • payment_tokens (+_passenger) للتتبع/عدم التكرار · admin_audit_log · paymentsLogSyria(Driver).

🔴 ثغرات وجدتها في كود سيرو — لا تُنقل كما هي:

الثغرة التفصيل
IDOR في request_payout.php driverId وphone يُؤخذان من الطلب لا من الـJWT → سائق يطلب سحب رصيد سائق آخر إلى هاتفه. يجب اشتقاق الهوية من التوكن حصراً.
لا خصم عند الطلب الطلب يفحص الرصيد ثم يُدرج سجلاً فقط بلا حجز → طلبات متعددة متزامنة تمرّ كلها (double-spend).
عمولة متناقضة request_payout يفحص balance >= amount + 3500 (العمولة فوق المبلغ)، وfinalize_payout يحسب netAmount = amount - 3500 ويخصم الصافي فقط (العمولة داخله) → تسريب مال. والرقم 3500 مكرر حرفياً في الملفين.
finalizePayout بلا معاملة 5 عمليات كتابة بلا beginTransaction/rollBack → فشل في المنتصف = خصم بلا تسجيل عمولة (حالة نصفية).
UPDATE payments SET isGiven=TRUE WHERE driverID=… AND isGiven=FALSE يعلّم كل الدفعات المعلّقة كمدفوعة بغضّ النظر عن مبلغ السحب.
driverWallet.amount = varchar(10) المال مخزَّن كنص (وحساب SUM على varchar).
لا OTP على الـpayout phone_verification موجود لكنه مستخدم في التسجيل/الدخول فقط — لا شيء يحمي السحب. (ملاحظة المالك صحيحة.)
جدولان متداخلان payout_requests وdriver_withdrawal_requests لنفس المفهوم.

🔴 ثغرة في كودنا نحن (wallet.service.ts): credit/debit تعمل read-modify-write على عمود balance بلا قفل ولا معاملة → سباق حقيقي: خصمان متزامنان يقرآن نفس الرصيد ويكتبان فوق بعض = مال مفقود/مخلوق. سيرو هنا أفضل منّا (دفتر قيود + FOR UPDATE).

# البند التفصيل
I1 ✅ إصلاح سباق المحفظة منفَّذ ومُثبَت على السيرفر (commit 9d6b752): UPDATE … SET balance = balance ± :delta WHERE tenant_id … AND balance >= :amount RETURNING * — عبارة واحدة ذرّية، والقيد+الرصيد في معاملة واحدة. أُضيف wallet_txns.balance_after وقيد CHECK (balance >= 0) كشبكة أمان. إنشاء المحفظة عبر ON CONFLICT DO NOTHING.
نتيجة wallet-race-test.mjs 100 5 على Postgres حقيقي: 100 خصم متزامن → نجح 50 بالضبط، رُفض 50، الرصيد 250→0، المخصوم = 250 (لا مال ضائع ولا مخلوق)، الزمن 1492ms. ✅
# البند الحالة
--- ------- --------
I4 OTP على الـpayout عبر نبيه ✅ تدفّق خطوتين: POST /payouts/request يرسل رمزاً بلا حركة مال، وPOST /payouts/:id/confirm يتحقق ثم يحجز ذرّياً. حدّ 5 محاولات، الرمز يُستهلك مرة واحدة، وcomplete يرفض طلباً بلا otp_verified_at. قاعدة: لا يتحرك مال قبل إثبات الهوية.
I5 بصمة (وجه/إصبع) ✅ الجزء الخلفي: biometric_method/biometric_at + device_id + request_ip تُسجَّل مع السحب. أثرٌ للتحقيق لا مصادقة — العميل يستطيع ادّعاءها؛ المصادقة الحقيقية JWT + رمز واتساب. الإثبات الحيّ نفسه في فلاتر.
I6 HMAC على العمليات المالية 🟡 مبنيّ ومطفأ (PAYMENTS_REQUIRE_SIGNATURE=false) — يحتاج فلاتر أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. مفتاح لكل جلسة يُصدره الدخول (signing_key) لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع timestamp.METHOD.path.body بنافذة 5 دقائق.
I7 سجل تدقيق مالي ✅ tripz_audit_log (append-only): من · ماذا · متى · من أي IP وجهاز. يغطّي payout.request/confirm/complete/fail وotp_failed/otp_blocked وsignature.rejected. لا يرمي أبداً — فشل التدقيق لا يُسقط عمليةً نجحت.
I2 محفظة المنصة ⏳ أُلغيت الحاجة إليها عملياً: بعد نموذج الرصيد التشغيلي (docs/18) لم تعد المنصة تقبض عمولة من كل رحلة — إيرادها = الشحن، وcredit_txns هو دفتر ذلك الإيراد فعلاً (SUM الشحن لكل مستأجر). محفظة ثانية = مسك دفتر مزدوج يحتاج مطابقة. البند الباقي = تقرير إيراد لا محفظة.
I3 جدول لكل طريقة دفع ⏳ لم يُنفَّذ — إعادة هيكلة مخطط كاملة (tripz_pay_payments عام حالياً). يُنفَّذ مع أول ربط بوابة حقيقية، لا قبله.
I8 webhook SMS + Gemini ⏳ لم يُنفَّذ — ميزة كاملة (raw_sms_log + استخراج + تسوية). تُبنى عند دخول السوق السوري فعلياً.

🔴 TLS — أخطر ثغرة مالية كانت قائمة ✅ منجَز ومُثبَت. المستند: 20-tls

مُثبَت على السيرفر (2026-07-17): https://tripz-api.intaleqapp.com/api/health → 200 عبر nginx+شهادة. http://194.163.173.157:4010/api/health → Connection refused. المنفذ الخام لم يعد قابلاً للوصول من الإنترنت إطلاقاً. الـAPI كان على http بلا TLS: من يلتقط الشبكة يسرق توكن أي سائق ويسحب أرباحه، ولا OTP ولا HMAC ولا تدقيق يمنع ذلك. النطاق المعتمد: tripz-api.intaleqapp.com. الجزء البرمجي ✅ (المنافذ + trust proxy)؛ الباقي خطوات سيرفر يدوية في docs/20.

وأثناء العمل ظهرت ثغرتان تُبطلان TLS من أصله لو تُركتا:

  • 4010 كان مكشوفاً على 0.0.0.0 → صار 127.0.0.1. بلا هذا يبقى http://IP:4010 مفتوحاً فيتجاوز TLS كلياً والشهادة زينة.
  • Postgres كان مكشوفاً على 0.0.0.0:55432 — أي القاعدة على الإنترنت مباشرة → صار 127.0.0.1 (الوصول من الماك عبر نفق SSH).
  • trust proxy = 1: خلف Nginx كان req.ip سيصير 127.0.0.1 للجميع فيمتلئ سجل التدقيق المالي (I7) بعنوان البروكسي بدل السائق — أثرٌ بلا قيمة عند النزاع. والقفزة الواحدة مقصودة: الثقة المفتوحة تسمح بتزوير X-Forwarded-For.

قرار: هل يخاطب فلاتر خرائط انطلق مباشرة؟

القرار المتّخذ (2026-07-17): تقسيم حسب نوع النداء — لا «كله مباشر» ولا «كله عبر السيرفر».

النوع المسار السبب
البلاطات (tiles) فلاتر → انطلق مباشرة حجم كبير ومتكرر، لا يحمل أسراراً، ويُخزَّن مؤقتاً على الجهاز. تمريره عبر سيرفرنا = تضخيم عرض النطاق بلا فائدة.
geocode / reverse / route / places فلاتر → سيرفرنا → انطلق يحمي مفتاح API (مفتاح داخل التطبيق = مسروق)، يتيح كاش Redis، حصص لكل مستأجر، وتبديل المزوّد بلا تحديث التطبيق.

السرعة ليست مقايضة هنا — بالعكس: اختبار التحميل عندنا أظهر أن p50 قفز إلى 2.9 ثانية والسبب المهيمن هو نداء HTTP الخارجي إلى انطلق. كاش النتائج في Redis (نفس العنوان يُطلب آلاف المرات) يجعل المسار عبر سيرفرنا أسرع من النداء المباشر، لا أبطأ. الإضافة الصافية للسيرفر ~10–30ms مقابل توفير ~2.9s على كل إصابة كاش. → البند المطلوب: كاش Redis لنتائج geocode/route/places (يُضاف للمجموعة G).



المجموعة J — الرصيد التشغيلي وعمولة السائق — ✅ منفَّذة (عدا J5)

المستند الكامل: 18-driver-credit-commission. السائق يشحن رصيداً تشغيلياً سلفاً · الراكب يدفع له الأجرة كاملة · العمولة تُخصم من الرصيد لا من الأجرة. نموذج معتمد عالمياً في أسواق الكاش (inDrive · Yango · سيرو) — وليس نموذج أوبر.

# البند الحالة
J1 driver_credits + credit_txns — خصم ذرّي (نمط I1) لكن بلا شرط رصيد: الدين مسموح عمداً، ولا قيد >= 0 على الجدول ✅
J2 خصم العمولة عند completed من الرصيد التشغيلي — كاشاً كانت أو محفظة (قاعدة واحدة) ✅
J3 تصحيح B6: price_for_driver = price_for_passenger؛ TariffEngine.split → commission (بلا سقف بالأجرة) ✅
J4 الحجب عند تجاوز credit_floor فقط — عند setOnline وaccept، لا أثناء رحلة جارية ✅
J6 مكافأة التسجيل عند الاعتماد لا التقديم: 3 JOD · 300 SYP · 300 EGP. حارسان: فحص تطبيقي + فهرس فريد جزئي ✅
J7 credit_floor لكل عملة: −5 JOD · −500 SYP · −500 EGP ✅
J8 GET /credit (الرصيد · الأرضية · مَدين؟ · محجوب؟) وGET /credit/transactions ✅
— تصحيح B3: مشوار الوصول = تعويض إلغاء بعد انقضاء الانتظار المجاني — لا بند في كل أجرة ✅
J5 باقات شحن لكل عملة (promo_bonus جاهز كنوع قيد؛ الباقات نفسها لم تُبنَ) ⏳

الأرقام ثوابت في driver-credit.service.ts مؤقتاً (المكافأة والأرضية) — تُنقل لإعداد المستأجر عند بناء J5.

أثره على I2: محفظة المنصة لم تعد تستقبل عمولة من كل رحلة — بل تستقبل مدفوعات الشحن. هذا يبسّط I2 كثيراً.


المجموعة K — الاستحقاقات ومنع تفعيل الميزات بلا اشتراك — ✅ منفَّذة

المستند الكامل: 19-entitlements-licensing. القاعدة: علم الميزة في التطبيق قرار عرض لا حدّ أمني. الحدّ الحقيقي حارس على السيرفر.

# البند الحالة
K1 FeatureGuard + @RequiresFeature() — الاستحقاقات من Redis والقاعدة احتياط. الوحدة عالمية عمداً: إجبار كل وحدة على استيرادها يعني نقطة منسيّة يوماً ما، والمنسيّة ميزةٌ مجانية للجميع ✅
K2 common/entitlements/features.ts — الكتالوج + افتراضات كل باقة. الافتراض هو المنع: قائمة بيضاء، وميزة جديدة تبقى محجوبة حتى تُمنح ✅
K3 GET /admin/features · GET /admin/tenants/:id/entitlements · PATCH /admin/tenants/:id/subscription (يدمج features لا يستبدلها، ويُبطل الكاش فوراً) ✅
K4 drivers_max يُفرض عند drivers.apply — عند الإنشاء فقط، فسائق قائم لا يُطرد بتغيير باقة ✅ (cities_max معرَّف بلا نقطة إنفاذ بعد)
K5 GET /tenant/config يرجع الاستحقاقات المحسوبة لا features الخام (الخام تجاوزات فقط، فكانت ستُظهر ميزات الباقة مطفأة) — موثّقة كعرض لا حماية ✅
K6 feature.guard.spec + features.spec — منها اختبار أن الترويسة لا تزوّر المستأجر، وأن الحارس يقرأ التوكن وحده ✅
K7 السيادة = كل الميزات بلا حدود، قراراً مقصوداً (لا وهم حماية تقنية) ✅

نقاط محروسة الآن: dispatch · chat · payments/charge. المدفوعات محروسة لكل نقطة لا على مستوى الصنف — الـwebhook يناديه المزوّد بلا JWT فكان حارس الصنف سيمنعه.

تغيير مقصود على الـseed: المستأجر التجريبي siro صار sovereign بدل brand. سببان: يطابق قرار المالك («سيرو أول مشترك، له الباقة الكاملة»)، ويمنع حجب dispatch وحدّ drivers_max: 500 من إسقاط سكربتات التحقق (كل تشغيلة تحميل تُنشئ عشرات السائقين وهم يتراكمون).

حارس السوبر-أدمن ✅: PlatformGuard (x-platform-secret, مقارنة ثابتة الزمن) يحرس كل admin/tenants* وadmin/features وplatform/*. سرّ غير مضبوط = منع الجميع لا فتح الباب. حرجٌ لأنه بدونه يرقّي أدمن أي مستأجر اشتراكه بنفسه — أي أن K كلها كانت زينة. admin/users/:id/role تبقى لأدمن المستأجر (نطاقها من التوكن) لأنها ليست نقطة منصة.

سياسة الدين لكل مستأجر ✅ (قرار المالك): tenants.settings.credit — debt_allowance وsignup_bonus لكل عملة، يضبطهما أدمن المستأجر عبر PATCH /credit/policy. الافتراضات 2 JOD · 200 SYP · 200 EGP، و0 خيار صريح = لا دين. settings منفصلة عن features عمداً: تلك «ماذا اشتريت» (سوبر-أدمن)، وهذه «كيف تشتغل» (المستأجر). هجرة TenantSettings.

ما تبقّى: نقطة إنفاذ لـcities_max (لا مفهوم «مدينة» مُدار بعد)، ونموذج مستخدم منصة حقيقي بدل السرّ المشترك.


المجموعة L — الإحالات والمكافآت والكوبونات — ✅ منفَّذة

قرار المالك 2026-07-18. قانونان ثابتان يحكمان المجموعة كلها:

  1. محفظة الراكب لا تُسحب أبداً — فمكافأة الراكب كوبون خصم لا رصيد محفظة. إنزالها في المحفظة كان يخلق التزاماً نقدياً قابلاً للسحب مقابل حملة تسويقية.
  2. كل خصم مسقوف بعملته — 25 جنيهاً في مصر · 25 ليرة سورية · 0.25 دينار (نفس نسبة credit-policy). السقف حدّ أعلى مطلق لا افتراض.
# البند الحالة
L1 referral_codes (كود شخصي ثابت) + referrals بحالات pending → qualified → paid ✅
L2 نوع قيد referral_bonus في credit_txns + grantReferralBonus بحارسَي J6 (فحص تطبيقي + فهرس فريد جزئي على ref) ✅
L3 محرّك الكوبونات: coupons + coupon_redemptions. يخصم من price_for_passenger وحده — price_for_driver يبقى كاملاً (docs/18 §52) ✅
L4 شرط الاستحقاق: بعد أول رحلة مكتملة للمُحال لا عند التسجيل. qualifying_trips لا ينزل تحت 1 ولو ضبطه الأدمن صفراً ✅
L5 RewardsSweeper — نمط ScheduledTripsSweeper (setInterval + قفل running). شبكة أمان لا مسار أساسي: الصرف يقع لحظة التأهّل ✅
L6 tenants.settings.rewards بنفس بنية credit-policy — سقف الخصم ومكافآت الطرفين لكل عملة ✅
L7 ربط fraud.service — كشف حلقات الإحالة والأجهزة المكرّرة ⏳
L8 باقات الشحن (J5) فوق نفس المحرّك ⏳

اتجاهات الإحالة الأربعة ليست أربعة مسارات: شكل المكافأة يتبع دور المستفيد لا اتجاه الإحالة — سائق ← رصيد تشغيلي، راكب ← كوبون. والدور يُحسم وقت الصرف من وجود صفّ سائق لا من الدور المخزَّن وقت التسجيل، فمن سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً لا كوبوناً.

حرّاس الاحتيال (على القاعدة لا بالفحص التطبيقي وحده):

  • فريد على (tenant_id, referee_user_id) — الشخص يُحال مرة واحدة في عمره. بدونه تُعاد نفس الضحية تحت عشرة داعين.
  • CHECK (referrer <> referee) — إحالة النفس.
  • فريد جزئي على credit_txns(tenant_id, ref) WHERE type='referral_bonus' وعلى coupons(tenant_id, ref) — الماسح والصرف الفوري قد يلتقيان على إحالة واحدة.
  • فريد على coupon_redemptions(tenant_id, trip_id) — كوبون واحد لكل رحلة.

درس مثبَّت باختبار: المحاولة الأولى حسبت عتبة التأهّل داخل SQL (CASE WHEN qualifying_trips + 1 >= n)، فجُمع العمود نصّاً ('0' + 1 = '01') ولم تتحقق الشرطية أبداً — كل إحالة تبقى معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى مستخدم. الحساب صار في TypeScript عبر RETURNING، والتزامن محفوظ بشرط الحالة في كلتا العبارتين.

العمولة تُحتسب قبل خصم الكوبون عمداً: الكوبون تنازل تسويقي من المنصة، فاحتسابها بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب ومرة بعمولة أنقص من السائق.


المجموعة M — أساس التسعير: تعرفة الانطلاق والمسافة الحقيقية — ✅ منفَّذة

قرار المالك 2026-07-19. الحدّ الأدنى للرحلة: 1.10 دينار · 20 جنيهاً · 150 ليرة — وهذه المرساة التي تُشتقّ منها كل الأرقام الأخرى.

# البند الحالة
M1 default-tariffs.ts — مصدر حقيقة واحد يستهلكه SeedService ومولّد الـSQL معاً ✅
M2 city = 'default' بدل اسم البلد؛ البلد من country_pack للمستأجر ✅
M3 نوافذ التعرفة بالتوقيت المحلي (timezone في التعريف) عبر Intl — لا UTC ولا توقيت الخادم ✅
M4 فتحة العدّاد (flag) حقيقية في كل نافذة، ورسم حجز (booking_fee) ✅
M5 fixed_quote منفَّذ فعلاً — لا مسافة ولا زمن ولا حدّ أدنى؛ الانتظار وحده يُضاف ✅
M6 الفئات مشتقّة بمعاملات: أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP ✅
M7 TripDistanceService — المسافة من نبضات GPS مع تنقية الضجيج والقفزات ✅
M8 إعادة التسعير عند الإنهاء على المسافة المقطوعة فعلاً، بسقف routed × 1.25 ✅
M9 زرع التعرفة لكل فئة خدمة لا الاقتصادي وحده ✅
M10 سماحية المسافة لكل مستأجر + ربط الانحراف بـfraud.service ⏳

الأعطال التي أُصلحت (كلها كانت فشلاً صامتاً):

  • رحلة مجانية: السكربت القديم كتب city='jordan'، والكود يبحث بـ'default' → لا تعرفة → quotedFare = null → settleFare تأخذ quoted_fare ?? 0 → أجرة صفر وعمولة صفر، بسطر تحذير واحد في اللوج.
  • ذروة لا تُحصَّل أبداً: pickWindow قرأ getUTCHours()، فذروة 16:00–20:00 تقع 19:00–23:00 في عمّان.
  • الفان أرخص من الاقتصادي في مصر وسوريا (22 مقابل 29.5 جنيهاً لنفس الرحلة) — أي أن الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل.
  • حدّ أدنى ثلث الحقيقي: perKm × 2 = 0.368 ديناراً بدل 1.10.
  • fixed_quote لم يكن ثابتاً: كان يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه.
  • السكربت لم يعمل أصلاً: require في ملف ESM؛ ولا وجود لـseed-tariffs.sql إطلاقاً.
  • تشغيلتان = تعرفتان فعّالتان بنفس النسخة، وgetActive ترتّب version DESC فالاختيار بينهما غير محدَّد.

سياسة المسافة (M7/M8): يُحاسَب الأطول من (المقاس، المقدَّر) بسقف routed × 1.25. التحويلة الحقيقية يقبضها السائق، والجولة المخترَعة تُقصّ وتُعلَّم للمراجعة لا تُخصم صامتة. والطريق الأقصر يُحاسَب بالأقصر — الراكب لا يدفع تقديراً لم يُقطع. نبضات أقل من 5 = رجوع للسعر المقفول: رقم مخترَع من الضجيج أسوأ من تقدير معقول.

تصحيح على المجموعة L: سقوف الكوبونات كانت مبنية على نسبة credit-policy القديمة (1:100:100)، وهي لا تطابق مرساة المالك (1.10 = 20 = 150، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة). السقف 25 جنيهاً صار يقابل 1.375 ديناراً و190 ليرة بدل 0.25 و25 — النسخة الأولى كانت تعطي سوريا سُدس العرض.


مؤجَّل عمداً (قرار المالك)

المفاوض الذكي · تدرّج السائق · خصم العمولة — آخر شيء (جديدة حتى على سيرو). Geofence — مؤجَّل («لوقتها»)، موجود في سيرو للاستئناس.


ترتيب التنفيذ المقترح

  1. A (الزمن الحقيقي + FCM + Redis + race) ✅ منفَّذة ومُثبَتة على السيرفر.
  2. I1 (سباق المحفظة) ✅ منفَّذة ومُثبَتة بتزامن حقيقي.
  3. G (Redis خط أول: لغة/توكنات/tenant/تعرفة/تقييم/كاش الخرائط) ✅ منفَّذة.
  4. H (المواقع: إيقاف الكتابة لكل نبضة + batching + tracks) ✅ منفَّذة (عدا H6/H7 — تحليلات مؤجَّلة).
  5. B — ✅ الشريحتان الأولى والثانية (B2·B3·B6·B7 · B4 stops · B5 جدولة · B8 مطابقة الوجهة). الباقي تحسينات: B9 (واجهة تعرفة) · B10 (كتالوج أنواع عالمي) · B11 (تعرفة بالوزن).
  6. I الباقي (المدفوعات: جداول لكل طريقة + OTP/بصمة/HMAC للسحب).
  7. C (بيانات المركبة والسائق + ai_data).
  8. D (تطبيع الهاتف + بصمة الجهاز + HMAC).
  9. E (الاحتيال) ثم F.