Update: 2026-07-10 03:04:06

This commit is contained in:
Hamza-Ayed
2026-07-10 03:04:06 +03:00
parent 2ff7450d0b
commit b18fd027b6
22 changed files with 2661 additions and 96 deletions
Vendored
BIN
View File
Binary file not shown.
+11
View File
@@ -226,6 +226,17 @@ try {
error_log("[add_ride] ride DB insert success. RideID=$insertedId");
// 🆕 Seed initial ride state in Redis — fired as early as possible so
// getRideStatus polling has a cache entry from the first poll onward.
// Uses $status/$driver_id/$passenger_id, the exact values just written
// to MySQL above, not hardcoded literals.
sendToLocationServer('update_ride_state', [
'ride_id' => $insertedId,
'status' => $status,
'driver_id' => $driver_id,
'passenger_id' => $passenger_id,
]);
// ═══════════════════════════════════════════════════════════
// STEP B — primary DB ثانياً (نسخة أرشيفية بنفس الـ ID)
// ═══════════════════════════════════════════════════════════
+4 -2
View File
@@ -212,10 +212,12 @@ try {
$con->commit();
// 🆕 Cache ride state in Redis
// 🆕 Cache ride state in Redis — use $statusText (the exact value just
// written to MySQL above), not a hardcoded label, so a cache hit can
// never disagree with the row it was seeded from.
sendToLocationServer('update_ride_state', [
'ride_id' => $rideId,
'status' => 'cancelled_by_driver',
'status' => $statusText,
'driver_id' => $driverId,
'passenger_id' => $passenger_id ?? '',
]);
@@ -313,6 +313,17 @@ try {
// ✅ Payment succeeded — COMMIT
$con->commit();
// 🆕 Cache ride state in Redis — placed immediately after commit(), before
// the best-effort remote-DB sync / streak / notification code below, so a
// later exception in any of those non-critical steps can never suppress
// this write for a ride that's genuinely finished in MySQL.
sendToLocationServer('update_ride_state', [
'ride_id' => $rideId,
'status' => $newStatus,
'driver_id' => $driver_id,
'passenger_id' => $passengerId,
]);
// 🔥 [Fix Split-Brain] تحديث القاعدة البعيدة الآن فقط، بعد أن أصبح الدفع
// والتحديث المحلي مؤكدَين نجاحهما — يبقي الحالتين متطابقتين دائماً.
// فشل هذا التحديث best-effort فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً).
@@ -134,6 +134,17 @@ try {
':id' => $rideId,
]);
// 🆕 Refresh Redis cache — this UPDATE just reset status back to 'waiting'
// and driver_id to 0. Without this, a passenger polling during a retry
// round would keep seeing a stale prior state (e.g. 'arrived') cached
// from before the reset.
sendToLocationServer('update_ride_state', [
'ride_id' => $rideId,
'status' => 'waiting',
'driver_id' => '0',
'passenger_id' => $passengerId,
]);
// 3. حساب العمولة (Kazan) المُحدَّثة بعد الـ bonus
$kazan = (float)$price - (float)$priceForDriver;
if ($kazan < 0) $kazan = 0; // Floor protection
+4 -2
View File
@@ -142,10 +142,12 @@ try {
$con->commit();
// 🆕 Cache ride state in Redis
// 🆕 Cache ride state in Redis — use $status (the exact value just
// written to MySQL above), not a hardcoded label, so a cache hit can
// never disagree with the row it was seeded from.
sendToLocationServer('update_ride_state', [
'ride_id' => $ride_id,
'status' => 'started',
'status' => $status,
'driver_id' => $driver_id,
'passenger_id' => $passenger_id ?? '',
]);
+290
View File
@@ -0,0 +1,290 @@
# تقرير التدقيق وتصميم بنية سيرفرات منصة Siro (نسخة محدثة ونهائية)
بناءً على التوضيح الدقيق للهيكلية الفعلية المعتمدة للمنظومة، يهدف هذا التقرير إلى تقديم تفصيل كامل للمكونات، ورسم طوبولوجيا الشبكة المطابقة للواقع، وحل مشكلة اتصال وتزامن خوادم الـ WebSockets مع خوادم الباك إند والعملاء.
---
## أولاً: الهيكلية المعتمدة للنظام (Actual Architecture Topology)
تتكون المنظومة من المكونات الأساسية التالية وتوزيعها كالتالي:
1. **موزع الأحمال (Load Balancer):** يقع في المقدمة لتلقي طلبات الـ API (HTTP/HTTPS) فقط وتوزيعها على خادمين.
2. **خوادم الـ API (Web Server A & B):** خوادم Stateless تستقبل الطلبات وتقوم بالمعالجة وحفظ الملفات، وتتصل بكل من قاعدة البيانات وذاكرة Redis المؤقتة.
3. **قاعدة البيانات الرئيسية (MySQL Master):** تحتوي على البيانات الأساسية ولها خادمان تابعان:
* **نسخة مطابقة (Replica):** للمزامنة الحية وتخفيف ضغط القراءة.
* **نسخة نسخ احتياطي (Dump Server):** لأخذ النسخ الاحتياطية الدورية دون التأثير على الأداء.
4. **خوادم الـ Redis:** تعمل كطبقة كاش ذكية أولى/ثانية أمام قاعدة البيانات لحفظ الجلسات، التحقق، ومعدل الطلبات، ولها خادم احتياطي (Replica).
5. **خوادم الـ WebSockets المنفصلة:**
* **سيرفر الموقع الجغرافي (Location Server):** خادم مستقل يستقبل إحداثيات GPS حية من تطبيق السائق.
* **سيرفر الرحلات (Ride/Passenger Server):** خادم مستقل يدير قنوات الاتصال الحي بين الراكب والسائق أثناء الرحلة.
* **ملاحظة:** تطبيق الفلاتر (الراكب والسائق) يتصل **مباشرة** بهذين السيرفرين عبر بروتوكول WS/WSS.
---
## ثانياً: حل معضلة اتصال الـ WebSockets (The WebSocket Integration Solution)
> [!IMPORTANT]
> ### السؤال الأساسي: كيف تتواصل خوادم الباك إند (Web Server A & B) مع خوادم الـ Sockets المنفصلة وتزامن حالة الرحلة؟
> بما أن خوادم الباك إند معزولة خلف موزع الأحمال، وتطبيقات الموبايل متصلة مباشرة بخوادم الـ Sockets، فإن أفضل طريقة تشغيلية ومعمارية للربط هي استخدام **Redis Pub/Sub (نظام النشر والاشتراك)** كجسر تواصل سريع جداً (Event Bus):
```mermaid
sequenceDiagram
autonumber
actor Driver as تطبيق السائق
participant WebAPI as خادم الباك إند (A أو B)
participant Redis as Redis Pub/Sub
participant RideSocket as سيرفر الـ Sockets (Ride)
actor Rider as تطبيق الراكب
Driver->>WebAPI: 1. قبول الرحلة (طلب API)
WebAPI->>WebAPI: 2. معالجة الطلب وحفظ الحالة بقاعدة البيانات
WebAPI->>Redis: 3. نشر حدث (Publish) -> "قبول_رحلة" لقناة Redis
Note over Redis: الحدث يحتوي على معرف الرحلة والراكب
Redis-->>RideSocket: 4. استلام الحدث فوراً (Subscription)
RideSocket->>Rider: 5. دفع تنبيه حقيقي (WebSocket Push) -> "السائق قبل رحلتك"
```
### مزايا هذا الحل:
* **Stateless API:** تبقى خوادم الباك إند خفيفة ولا تحتاج لمعرفة من متصل بأي سيرفر سوكت.
* **تزامن فوري:** تتم عملية النشر والاستلام عبر الـ Redis في أجزاء من الميلي ثانية.
* **فصل كامل للمسؤولية:** خوادم السوكت تركز فقط على الحفاظ على الاتصالات المفتوحة وإرسال البيانات، بينما خوادم الباك إند تركز على منطق العمل وحساب الأسعار.
---
## ثالثاً: مخطط طوبولوجيا الشبكة المقترح (Network Topology Diagram)
يوضح المخطط التالي العلاقة التفاعلية ومسارات اتصال تطبيقات الموبايل بالمنظومة:
```mermaid
graph TD
%% Clients
Rider([تطبيق الراكب])
Driver([تطبيق السائق])
%% API Routing
Rider -.->|1. API Requests HTTPS| LB[Load Balancer]
Driver -.->|1. API Requests HTTPS| LB
LB --> WebA[Web Server A]
LB --> WebB[Web Server B]
%% Direct Web Sockets Connection
Rider ==>|2. WebSocket WSS| RideSocket[Ride/Passenger Server]
Driver ==>|2. Live Location WSS| LocationSocket[Location Server]
%% Shared Storage for Uploads
WebA -->|Uploads| SharedStorage[(Shared Storage / NFS)]
WebB -->|Uploads| SharedStorage
%% Caching & PubSub Bridge
WebA --> RedisMaster[Redis Master]
WebB --> RedisMaster
RideSocket --> RedisMaster
LocationSocket --> RedisMaster
RedisMaster -->|Replication| RedisReplica[Redis Replica]
%% Databases
WebA --> DBMaster[(MySQL Master DB)]
WebB --> DBMaster
LocationSocket -.->|Save Trackings| DBMaster
RideSocket -.->|Save Ride States| DBMaster
%% DB Backup & Replication
DBMaster -->|Streaming| DBReplica[(MySQL Replica DB)]
DBMaster -->|Dumps| DBBackup[(MySQL Dump Server)]
```
---
## رابعاً: معالجة البيانات والتخزين المؤقت (Redis Caching Strategy)
* **البيانات المخزنة في Redis:** الجلسات النشطة (Sessions)، التوكنات المؤقتة للتحقق (OTP & JWT)، معدل الطلبات (Rate Limiting)، والبيانات الجغرافية المؤقتة للسائقين القريبين.
* **البيانات في MySQL:** بيانات المستخدمين، تفاصيل الرحلات، الحسابات المالية، سجلات الحظر والوثائق.
* عند طلب بيانات معينة (مثل الملف الشخصي)، يفحص السيرفر الـ Redis أولاً؛ إن لم يجدها (Cache Miss) يقرأها من MySQL Master/Replica ويخزنها في Redis لمرة ثانية لتسريع الطلبات القادمة.
---
## خامساً: خطة الشراء واختيار السيرفرات (Hosting & Sizing Recommendations)
بناءً على عروض الاستضافة المتاحة من شركتي **Netcup** و **Contabo**، تم تصميم خطة الشراء التالية لتوفر أفضل أداء مقابل السعر مع تحقيق توازن كامل للمنظومة:
### ١. مبررات اختيار الشركات لكل خدمة:
* **سيرفرات قواعد البيانات والـ Sockets والـ Redis (اختيار Netcup):** قواعد البيانات والاتصالات الحية تحتاج إلى **استقرار كامل للـ CPU** ونوعية رام مدعومة بكشف الأخطاء وتصحيحها (**ECC DDR5 RAM**)، بالإضافة إلى سرعة قراءة وكتابة فائقة للأقراص (NVMe SSD)، وهو ما تتفوق فيه Netcup بشكل قطعي.
* **سيرفرات الـ API المستقلة وسيرفر الخرائط (اختيار Contabo):** خوادم الـ API هي Stateless وتتطلب سعة ذاكرة رام عالية وأنوية معالجة بتكلفة اقتصادية لتوزيع الأحمال. كما أن سيرفر الخرائط يحتاج لمساحة ديسك ضخمة ورام كبيرة جداً لتحميل ملفات الـ PBF والـ Tiles، وتوفر Contabo أحجام رام ضخمة بأسعار منافسة جداً.
---
### ٢. جدول توزيع المشتريات ومواصفات السيرفرات (١٢ سيرفر):
| # | السيرفر ووظيفته | المواصفات المطلوبة | الشركة والخطة المقترحة | التكلفة (€/شهر) | التكلفة ($/شهر) | نوع القرص والتخزين |
| :---: | :--- | :---: | :--- | :---: | :---: | :---: |
| 1 | **موزع الأحمال (Load Balancer)** | 2GB RAM / 2 Cores | **Netcup:** VPS nano G11s | €2.58 | $2.92 | 60 GB SSD |
| 2 | **خادم الـ API الأول (Web Server A)** | 8GB RAM / 4 Cores | **Contabo:** Cloud VPS 10 | €4.40 | $4.97 | 75 GB NVMe |
| 3 | **خادم الـ API الثاني (Web Server B)** | 8GB RAM / 4 Cores | **Contabo:** Cloud VPS 10 | €4.40 | $4.97 | 75 GB NVMe |
| 4 | **قاعدة البيانات الرئيسية (MySQL Master)** | 32GB RAM / 12 Cores | **Netcup:** VPS 4000 G12 | €27.00 | $30.51 | 1 TB NVMe |
| 5 | **قاعدة البيانات الاحتياطية (MySQL Replica)** | 16GB RAM / 8 Cores | **Netcup:** VPS 2000 G12 | €16.17 | $18.27 | 512 GB NVMe |
| 6 | **سيرفر النسخ الاحتياطي (Dump DB)** | 4GB RAM / 2 Cores | **Netcup:** VPS 500 G12 | €4.96 | $5.61 | 128 GB NVMe |
| 7 | **ذاكرة Redis الرئيسية (Redis Master)** | 2GB RAM / 2 Cores | **Netcup:** VPS nano G11s | €2.58 | $2.92 | 60 GB SSD |
| 8 | **ذاكرة Redis الاحتياطية (Redis Replica)** | 2GB RAM / 2 Cores | **Netcup:** VPS nano G11s | €2.58 | $2.92 | 60 GB SSD |
| 9 | **سيرفر تتبع المواقع الحية (Location Server)** | 4GB RAM / 2 Cores | **Netcup:** VPS 500 G12 | €4.96 | $5.61 | 128 GB NVMe |
| 10 | **سيرفر حالة الرحلات (Passenger Socket)** | 2GB RAM / 2 Cores | **Netcup:** VPS nano G11s | €2.58 | $2.92 | 60 GB SSD |
| 11 | **سيرفر المدفوعات المستقل (Payments)** | 2GB RAM / 2 Cores | **Netcup:** VPS nano G11s | €2.58 | $2.92 | 60 GB SSD |
| 12 | **سيرفر الخرائط المخصص (SiroMaps)** | 64GB RAM / 16 Cores | **Contabo:** Cloud VPS 50 | €29.60 | $33.45 | 300 GB NVMe |
---
### ٣. ملخص التكلفة الشهرية الإجمالية:
| البند | التكلفة باليورو (€) | التكلفة بالدولار ($) |
| :--- | :---: | :---: |
| **إجمالي سيرفرات Netcup (9 سيرفرات)** | €65.99 | $74.58 |
| **إجمالي سيرفرات Contabo (3 سيرفرات)** | €38.40 | $43.39 |
| **الإجمالي الكلي للمنظومة (12 سيرفر)** | **€104.39** | **$117.97** |
> [!NOTE]
> سعر الصرف المستخدم: 1 يورو = 1.13 دولار أمريكي (تقريبي). الأسعار المذكورة هي أسعار الاشتراك الشهري فقط ولا تشمل رسوم الإعداد (Setup Fee) إن وجدت.
---
## سادساً: تقييم الأداء — كم تستوعب هذه المنظومة؟ (Capacity Estimation)
> [!IMPORTANT]
> ### الجواب المختصر: هذه البنية كافية ومتينة جداً لمنصة نقل ركاب بحجم سوق سوريا ومصر في مراحل التشغيل والنمو الأولى والمتوسطة.
### تقدير السعة حسب كل مكوّن:
| المكوّن | السعة التقديرية القصوى | الشرح والمبرر |
| :--- | :---: | :--- |
| **خوادم الـ API (Web A + B)** | ~2,000 - 3,000 طلب HTTP متزامن | كل سيرفر PHP-FPM بـ 8GB يخدم حوالي 1,000-1,500 طلب متزامن. مع سيرفرين خلف اللود بلانسر، تتضاعف السعة. |
| **سيرفر الموقع الجغرافي (Location WebSocket)** | ~10,000 - 15,000 سائق متصل في نفس اللحظة | Workerman على 4GB DDR5 يدير اتصالات خفيفة جداً (إحداثيات GPS كل 3 ثوانٍ). القيد هنا هو عدد الـ File Descriptors وليس الرام. |
| **سيرفر الرحلات (Passenger Socket)** | ~5,000 - 8,000 راكب متصل في نفس اللحظة | خفيف جداً كونه relay. كل اتصال يستهلك ~50KB رام فقط. |
| **قاعدة البيانات الرئيسية (MySQL Master)** | ~5,000 - 8,000 استعلام في الثانية | 32GB مع `innodb_buffer_pool_size=22GB` يخدم ملايين الصفوف بسرعة فائقة. مع الـ Replica يتضاعف أداء القراءة. |
| **Redis Master** | ~100,000+ عملية في الثانية | Redis على 2GB يخدم مئات آلاف العمليات/ثانية. حجم البيانات المخزنة (جلسات + كاش + GEO) لن يتجاوز 500MB في الذروة. |
| **سيرفر الخرائط (SiroMaps)** | ~500 - 1,000 طلب توجيه/ثانية | OSRM على 64GB رام يحمّل خريطة سوريا ومصر بالكامل في الذاكرة لسرعة فائقة. |
### تقدير السعة الإجمالية للمنصة:
| المقياس | السعة التقديرية |
| :--- | :---: |
| **عدد المستخدمين المسجلين (ركاب + سائقين)** | حتى **500,000+** مستخدم مسجل |
| **عدد السائقين المتصلين في نفس اللحظة (أوقات الذروة)** | حتى **10,000 - 15,000** سائق |
| **عدد الركاب النشطين في نفس اللحظة** | حتى **5,000 - 8,000** راكب |
| **عدد الرحلات اليومية** | حتى **20,000 - 40,000** رحلة/يوم |
| **عدد الرحلات المتزامنة في نفس اللحظة** | حتى **1,500 - 3,000** رحلة نشطة |
> [!TIP]
> ### للمقارنة: تطبيقات نقل ركاب كبرى في المنطقة مثل "كريم" و"بولت" في مراحلها الأولى كانت تعمل بمنظومات أصغر من هذه. هذه البنية تكفي لتغطية سوريا ومصر بالكامل حتى الوصول لعشرات الآلاف من الرحلات اليومية.
### عوامل تحدد متى تحتاج للترقية:
| المؤشر | القيمة الحدّية التي توجب الترقية |
| :--- | :---: |
| استخدام CPU لخوادم الـ API يتجاوز | 75% بشكل مستمر لأكثر من ساعة |
| استخدام الرام لقاعدة البيانات يتجاوز | 85% من الـ Buffer Pool |
| عدد اتصالات السوكت يتجاوز | 80% من حد الـ File Descriptors |
| زمن استجابة الـ API يتجاوز | 500 ميلي ثانية كمتوسط |
| حجم قاعدة بيانات التتبع (Tracking) يتجاوز | 50GB بدون سياسة تنظيف (TTL) |
---
## سابعاً: الشبكة الداخلية (Private Network / VLAN) — هل هي ضرورية؟
> [!CAUTION]
> ### الجواب القاطع: نعم، الشبكة الداخلية (VLAN) **إلزامية وليست اختيارية** في هذا التصميم. بدونها أنت تعرّض قاعدة البيانات وخادم Redis للإنترنت العام مباشرة، وهذه كارثة أمنية.
### لماذا الشبكة الداخلية ضرورية؟
| السبب | التفصيل |
| :--- | :--- |
| **١. الأمان (Security)** | قاعدة البيانات MySQL وخادم Redis يجب أن يكونا **معزولين تماماً** عن الإنترنت العام. لا يجب أن يملك أي شخص خارج المنظومة القدرة على الوصول إليهما. الشبكة الداخلية تجعل هذين الخادمين مرئيين فقط لخوادم الـ API والسوكت. |
| **٢. الأداء (Performance)** | الاتصال عبر الشبكة الداخلية أسرع بكثير (latency أقل بـ 5-10 أضعاف) من الاتصال عبر الإنترنت العام. استعلامات قاعدة البيانات ستستجيب بـ 0.1ms بدلاً من 1-5ms. |
| **٣. التكلفة (Cost)** | الترافيك عبر الشبكة الداخلية **مجاني بالكامل** ولا يُحسب من حصة الباندويث. بينما الترافيك العام يُحسب ويُكلّف. |
| **٤. عزل الخدمات (Isolation)** | لو تم اختراق أحد خوادم الـ API (لا سمح الله)، لن يتمكن المهاجم من الوصول لقاعدة البيانات عبر الإنترنت العام لأنها ببساطة غير مرئية من الخارج. |
### كيف يتم التطبيق عملياً؟
```mermaid
graph TD
subgraph PublicZone["المنطقة العامة (Public Zone)"]
LB[Load Balancer]
LocationSocket[Location Server]
RideSocket[Ride Server]
end
subgraph PrivateVLAN["الشبكة الداخلية الخاصة (Private VLAN)"]
WebA[Web Server A]
WebB[Web Server B]
RedisMaster[Redis Master]
RedisReplica[Redis Replica]
DBMaster[(MySQL Master)]
DBReplica[(MySQL Replica)]
DBBackup[(Dump Server)]
Payments[Payments Server]
end
subgraph MapsZone["منطقة الخرائط (Maps Zone)"]
Maps[SiroMaps Server]
end
Internet([الإنترنت]) -->|HTTPS| LB
Internet -->|WSS| LocationSocket
Internet -->|WSS| RideSocket
LB -->|Private IP| WebA
LB -->|Private IP| WebB
WebA -->|Private IP| RedisMaster
WebA -->|Private IP| DBMaster
WebB -->|Private IP| RedisMaster
WebB -->|Private IP| DBMaster
LocationSocket -->|Private IP| RedisMaster
RideSocket -->|Private IP| RedisMaster
WebA -->|Private IP| Maps
```
### التطبيق العملي لكل شركة:
| الشركة | طريقة إنشاء الشبكة الداخلية | الملاحظة |
| :--- | :--- | :--- |
| **Netcup** | من لوحة التحكم SCP → "vLAN" → إنشاء شبكة افتراضية وربط السيرفرات بها | Netcup يوفر VLAN مجاني بين سيرفراتك في نفس مركز البيانات (Datacenter). اختر جميع سيرفرات Netcup في **نفس الموقع الجغرافي** (مثلاً Nürnberg). |
| **Contabo** | من لوحة التحكم → "Private Networking" → إضافة السيرفرات لنفس الشبكة | Contabo يوفر شبكة خاصة مجانية. اختر جميع سيرفرات Contabo في **نفس الموقع** (مثلاً EU-Germany). |
### الربط بين الشركتين (Netcup ↔ Contabo):
> [!WARNING]
> سيرفرات Netcup وسيرفرات Contabo في مراكز بيانات مختلفة فيزيائياً، لذلك **لا يمكن وضعها في نفس الـ VLAN**. الحل:
| الطريقة | التفصيل |
| :--- | :--- |
| **WireGuard VPN Tunnel** | إنشاء نفق VPN مشفّر بين سيرفر من Netcup وسيرفر من Contabo. هذا يجعلها تتصرف كأنها في نفس الشبكة الداخلية مع تشفير كامل. زمن الاتصال بين مراكز بيانات ألمانيا ≈ 1-3ms فقط. |
| **التطبيق:** | تثبيت WireGuard على موزع الأحمال (Netcup) وعلى خوادم الـ API (Contabo)، وإنشاء نفق بينهما. كل السيرفرات الأخرى تتصل عبر الشبكة الداخلية لشركتها. |
### قواعد الجدار الناري (Firewall Rules):
| السيرفر | المنافذ المفتوحة للإنترنت العام | المنافذ المفتوحة فقط للشبكة الداخلية |
| :--- | :---: | :---: |
| **موزع الأحمال** | 80, 443 (HTTP/HTTPS) | — |
| **سيرفر الموقع (Location)** | منفذ WSS المحدد | — |
| **سيرفر الرحلات (Ride)** | منفذ WSS المحدد | — |
| **Web Server A & B** | ❌ لا شيء | 80, 443 (يستقبل فقط من LB) |
| **MySQL Master & Replica** | ❌ لا شيء | 3306 (فقط من Web + Sockets) |
| **Redis Master & Replica** | ❌ لا شيء | 6379 (فقط من Web + Sockets) |
| **سيرفر المدفوعات** | ❌ لا شيء | المنفذ المحدد (فقط من Web) |
| **سيرفر الخرائط** | ❌ لا شيء | 5000, 8080 (فقط من Web) |
---
## ثامناً: التقييم النهائي
> [!TIP]
> ### الحكم العام: تصميم سليم ومتزن وجاهز للتنفيذ.
| السؤال | الإجابة |
| :--- | :--- |
| **هل التنظيم سليم؟** | نعم. فصل المسؤوليات واضح: API منفصل، DB منفصل، Sockets منفصل، Redis منفصل، Maps منفصل. كل مكوّن يمكن ترقيته أو استبداله بشكل مستقل دون التأثير على البقية. |
| **هل الموارد كافية؟** | نعم وزيادة. هذه البنية تخدم حتى 15,000 سائق و8,000 راكب متصلين في نفس اللحظة، و40,000 رحلة يومياً. أكبر من حاجة السوق السوري والمصري في المرحلة الحالية. |
| **كم عدد السيرفرات؟** | **12 سيرفر** موزعة بين 9 سيرفرات Netcup و3 سيرفرات Contabo. |
| **كم التكلفة الشهرية؟** | **€104.39 يورو = $117.97 دولار أمريكي شهرياً** |
| **هل نحتاج شبكة داخلية؟** | **إلزامية بشكل قاطع.** بدونها قاعدة البيانات و Redis مكشوفان على الإنترنت. الشبكة الداخلية مجانية من كلا الشركتين، ويتم ربطهما ببعض عبر WireGuard VPN. |
+18
View File
@@ -511,6 +511,24 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
$connection->send('OK');
// ── 7b. Get Ride State (Redis Cache Read-Only) ─────────
// Used by ride_server/intaleq/ride/rides/getRideStatus.php to
// answer passenger polling without hitting MySQL on every call.
} elseif ($action === 'get_ride_state') {
$rideId = (int)($post['ride_id'] ?? 0);
if ($rideId <= 0 || !$redis) {
$connection->send(json_encode(['status' => false, 'data' => null]));
return;
}
$stateData = $redis->hgetall("ride:{$rideId}:state");
$connection->send(json_encode([
'status' => !empty($stateData),
'data' => $stateData ?: null,
]));
// ── 5. Force Disconnect ───────────────────────────────
} elseif ($action === 'force_disconnect') {
$driverId = $post['driver_id'] ?? null;
+6
View File
@@ -0,0 +1,6 @@
{
"require": {
"firebase/php-jwt": "^7.0",
"workerman/phpsocket.io": "^2.2"
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
echo 'Hello World :-)';
+65
View File
@@ -0,0 +1,65 @@
<?php
// ride_server/intaleq/connect.php
// Bootstrap for standalone REST endpoints under ride_server/intaleq/.
// Modeled on loction_server/siro/connect.php (the JWT-enforcing variant).
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/load_env.php';
// .env path mirrors ride_server/passenger_socket.php's own already-deployed
// convention on this box — confirm/adjust with ops at deploy time.
$env_file = file_exists(__DIR__ . '/../ride-keys/.env')
? __DIR__ . '/../ride-keys/.env'
: (file_exists('/home/intaleq-rides/env/.env') ? '/home/intaleq-rides/env/.env' : '');
if (!empty($env_file)) {
loadEnvironment($env_file);
}
require_once __DIR__ . '/functions.php';
// --- CORS + JSON headers ---
header("Access-Control-Allow-Origin: https://intaleqapp.com");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
header('Content-Type: application/json');
date_default_timezone_set('Asia/Amman');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
// --- JWT auth — enforced. This endpoint returns ride status tied to a
// specific passenger/driver, and backend's connect.php enforces JWT for the
// same call today; dropping auth here would be a regression. ---
$decodedToken = authenticateJWT();
$user_id = $decodedToken->sub ?? $decodedToken->user_id ?? null;
// --- DB connection: same physical database backend's Database::get('main')
// resolves to (confirmed distinct from Database::get('ride') — the getRideStatus
// endpoint this replaces has always read from 'main', so the fallback query
// below stays on 'main' too, for zero behavior change vs. today). Env var
// names match backend/core/Database/Database.php's 'main' entry exactly;
// falling back to loction_server's simpler names only if ops sets this box
// up differently. Actual values must be confirmed/provisioned by ops. ---
$dbname = getenv('DB_PRIMARY_NAME_V2') ?: getenv('dbname');
$dbhost = getenv('DB_PRIMARY_HOST_V2') ?: 'localhost';
$dbuser = getenv('DB_PRIMARY_USER_V2') ?: getenv('USER');
$dbpass = getenv('DB_PRIMARY_PASS_V2') ?: getenv('PASS');
try {
$dsn = "mysql:host=$dbhost;dbname=$dbname;charset=utf8mb4";
$con = new PDO($dsn, $dbuser, $dbpass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES UTF8",
PDO::ATTR_TIMEOUT => 5,
]);
} catch (PDOException $e) {
error_log("[ride_server/connect] DB connection failed: " . $e->getMessage());
http_response_code(500);
echo json_encode(['status' => 'failure', 'message' => 'A database error occurred.']);
exit;
}
+95
View File
@@ -0,0 +1,95 @@
<?php
// ride_server/intaleq/functions.php
// Bootstrap helpers for standalone REST endpoints under ride_server/intaleq/.
// Mirrors the env/key conventions already deployed on this box for
// ride_server/passenger_socket.php (/home/intaleq-rides/...), not invented.
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
// Shared secret used to authenticate to loction_server's internal HTTP API —
// same mechanism ride_server/passenger_socket.php already uses on this box.
function getInternalSocketKey(): string {
$key = getenv('INTERNAL_SOCKET_KEY');
if ($key) return trim($key);
$path = getenv('INTERNAL_SOCKET_KEY_PATH') ?: '/home/intaleq-rides/.internal_socket_key';
if (file_exists($path)) return trim((string) @file_get_contents($path));
return '';
}
// JWT signing secret — must resolve to the same value backend's
// core/Auth/JwtService.php signs tokens with, or every token fails here.
function getJwtSecret(): string {
$keyPath = getenv('JWT_SECRET_KEY_PATH');
if ($keyPath && file_exists($keyPath)) {
return trim(file_get_contents($keyPath));
}
return getenv('JWT_SECRET_KEY') ?: '';
}
function authenticateJWT() {
$secretKey = getJwtSecret();
if (!$secretKey) {
error_log("[ride_server] JWT secret not configured.");
http_response_code(500);
echo json_encode(['status' => 'failure', 'message' => 'Internal server configuration error.']);
exit;
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = null;
if (preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) {
$token = $matches[1];
}
if (!$token) {
http_response_code(401);
echo json_encode(['status' => 'failure', 'message' => 'Authorization token required']);
exit;
}
try {
return JWT::decode($token, new Key($secretKey, 'HS256'));
} catch (ExpiredException $e) {
http_response_code(401);
echo json_encode(['status' => 'failure', 'message' => 'Token expired']);
exit;
} catch (SignatureInvalidException $e) {
http_response_code(401);
echo json_encode(['status' => 'failure', 'message' => 'Invalid token signature']);
exit;
} catch (BeforeValidException $e) {
http_response_code(401);
echo json_encode(['status' => 'failure', 'message' => 'Token not yet valid']);
exit;
} catch (Exception $e) {
http_response_code(401);
echo json_encode(['status' => 'failure', 'message' => 'Invalid token']);
exit;
}
// NOTE: signature/expiry only — does not replicate backend's
// core/Auth/JwtService::authenticate() JTI-blacklist (revoked-token)
// check or X-Device-FP verification. Flagged as a follow-up in the
// plan; not blocking for this read-only endpoint.
}
// Flutter's CRUD().get() always issues an HTTP POST under the hood
// (see siro_rider/lib/controller/functions/crud.dart _makeRequest/doPost) —
// so every field this endpoint reads comes through $_POST, never $_GET.
function filterRequest($requestname, $type = 'string') {
if (isset($_POST[$requestname]) && $_POST[$requestname] !== '') {
$value = trim($_POST[$requestname]);
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
if ($type === 'numeric') {
return filter_var($value, FILTER_VALIDATE_FLOAT) !== false ? $value : null;
}
return $value;
}
return null;
}
function printFailure($message = "none") {
echo json_encode(["status" => "failure", "message" => $message]);
}
+23
View File
@@ -0,0 +1,23 @@
<?php
function loadEnvironment($env_file) {
if (!file_exists($env_file)) {
error_log("❌ .env not found: $env_file");
return false;
}
$lines = file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || strpos($line, '#') === 0) continue;
$parts = explode('=', $line, 2);
if (count($parts) === 2) {
[$keyName, $value] = $parts;
$value = trim($value, "\"'");
putenv("$keyName=$value");
$_ENV[$keyName] = $value;
$_SERVER[$keyName] = $value;
}
}
return true;
}
@@ -0,0 +1,88 @@
<?php
// ride_server/intaleq/ride/rides/getRideStatus.php
// Serves https://rides.intaleq.xyz/intaleq/ride/rides/getRideStatus.php
// Same response contract as the legacy backend/ride/rides/getRideStatus.php:
// {"status":"success","data":"<ride status string>"}
//
// Reads Redis truth via loction_server's internal HTTP API first (same
// "ask location server, fall back to DB" shape as backend/ride/location/get.php);
// on any miss/timeout/error, falls back to a direct MySQL SELECT so behavior
// never regresses to "no answer."
require_once __DIR__ . '/../../connect.php';
$id = filterRequest("id");
$rideId = (int) $id;
if (empty($id) || $rideId <= 0) {
printFailure("Missing ride ID.");
exit;
}
// ── 1. Try Redis via the location server's internal HTTP API ──────────────
$status = null;
$locationServerUrl = getenv('LOCATION_SERVER_URL') ?: 'http://location.intaleq.xyz:2021';
$internalKey = getInternalSocketKey();
if ($internalKey) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $locationServerUrl);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'action' => 'get_ride_state',
'ride_id' => $rideId,
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Foreground/synchronous read — the answer IS the response, so this is
// deliberately longer than the 200-500ms fire-and-forget write timeouts
// used elsewhere in this codebase.
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 500);
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 1500);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: $internalKey"]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if (!$curlErr && $httpCode === 200 && $response) {
$json = json_decode($response, true);
if (is_array($json) && ($json['status'] ?? false) === true
&& !empty($json['data']['status'])) {
$status = $json['data']['status'];
}
} else {
error_log("[getRideStatus] location-server read miss/error for ride=$rideId: "
. ($curlErr ?: "HTTP $httpCode"));
}
} else {
error_log("[getRideStatus] internal key not configured — skipping Redis read, using DB fallback.");
}
// ── 2. Fallback: direct MySQL read (identical query to the legacy file) ───
if ($status === null) {
try {
$stmt = $con->prepare("SELECT `status` FROM `ride` WHERE `id` = :id");
$stmt->bindParam(':id', $rideId, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && isset($row['status'])) {
$status = $row['status'];
}
} catch (PDOException $e) {
error_log("[getRideStatus] DB fallback error for ride=$rideId: " . $e->getMessage());
http_response_code(500);
echo json_encode(["status" => "failure", "message" => "An internal error occurred."]);
exit;
}
}
if ($status !== null) {
echo json_encode([
"status" => "success",
"data" => $status
]);
} else {
printFailure("Ride not found.");
}
+191
View File
@@ -0,0 +1,191 @@
<?php
/**
* passenger_socket.php
* =====================
* WebSocket Server للركاب — بورت 3030
* Internal HTTP Server — بورت 3031
*/
use Workerman\Worker;
use PHPSocketIO\SocketIO;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
require_once __DIR__ . '/vendor/autoload.php';
// ---------------------------------------------------------
// نظام تسجيل الأحداث (Logging System)
// ---------------------------------------------------------
$LOG_FILE = __DIR__ . '/socket_debug.log';
function socket_log($message, $data = null) {
global $LOG_FILE;
$date = date('Y-m-d H:i:s');
$logMsg = "[$date] $message";
if ($data !== null) {
$logMsg .= " | DATA: " . (is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE));
}
$logMsg .= PHP_EOL;
echo $logMsg; // للطباعة في الكونسول إذا كان يعمل في الـ Foreground
@file_put_contents($LOG_FILE, $logMsg, FILE_APPEND); // الكتابة في الملف
}
// ---------------------------------------------------------
socket_log("=== STARTING PASSENGER SOCKET SERVER ===");
function loadEnvironment(string $filePath): void {
if (!file_exists($filePath)) {
socket_log("[WARNING] .env not found: $filePath");
return;
}
foreach (file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$name, $value] = explode('=', $line, 2);
putenv(trim($name) . '=' . trim($value, "\"'"));
}
}
loadEnvironment('/home/intaleq-rides/env/.env');
function getInternalSocketKey(): string {
$key = getenv('INTERNAL_SOCKET_KEY');
if ($key) return trim($key);
$path = getenv('INTERNAL_SOCKET_KEY_PATH') ?: '/home/intaleq-rides/.internal_socket_key';
if (file_exists($path)) return trim((string) @file_get_contents($path));
return '';
}
$INTERNAL_KEY = getInternalSocketKey();
function getJwtSecret(): string {
$keyPath = getenv('JWT_SECRET_KEY_PATH');
if ($keyPath && file_exists($keyPath)) {
return trim(file_get_contents($keyPath));
}
return getenv('JWT_SECRET_KEY') ?: '';
}
if (empty($INTERNAL_KEY)) {
socket_log("[CRITICAL_ERROR] Internal key missing! Exiting.");
exit(1);
}
$PORT = 3030;
$INTERNAL_PORT = 3031;
$io = new SocketIO($PORT);
$io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) {
$innerHttp = new Worker("http://0.0.0.0:$INTERNAL_PORT");
$innerHttp->onMessage = function ($connection, $request) use ($io, $INTERNAL_KEY) {
$headers = $request->header();
$clientIp = $connection->getRemoteIp();
if (($headers['x-internal-key'] ?? '') !== $INTERNAL_KEY) {
socket_log("[HTTP_ERROR] Unauthorized internal request from IP: $clientIp");
$connection->send('Unauthorized');
return;
}
$post = $request->post();
$action = trim($post['action'] ?? '');
if ($action === 'update_ride_status') {
$passengerId = $post['passenger_id'] ?? null;
$rawPayload = $post['payload'] ?? null;
if (!$passengerId || !$rawPayload) {
socket_log("[HTTP_ERROR] Missing passenger_id or payload for action: update_ride_status", $post);
$connection->send('Error: Missing passenger_id or payload');
return;
}
$payload = is_string($rawPayload)
? (json_decode($rawPayload, true) ?? $rawPayload)
: $rawPayload;
socket_log("[HTTP_SUCCESS] Emitting 'ride_status_change' to Passenger #$passengerId", $payload);
$io->to('passenger_' . $passengerId)->emit('ride_status_change', $payload);
$connection->send('OK');
} elseif ($action === 'update_driver_location') {
$passengerId = $post['passenger_id'] ?? null;
$rawPayload = $post['payload'] ?? null;
if (!$passengerId || !$rawPayload) {
socket_log("[HTTP_ERROR] Missing passenger_id or payload for action: update_driver_location", $post);
$connection->send('Error: Missing passenger_id or payload');
return;
}
$payload = is_string($rawPayload)
? (json_decode($rawPayload, true) ?? $rawPayload)
: $rawPayload;
socket_log("[HTTP_SUCCESS] Emitting 'driver_location_update' to Passenger #$passengerId", $payload);
$io->to('passenger_' . $passengerId)->emit('driver_location_update', $payload);
$connection->send('OK');
} else {
socket_log("[HTTP_WARNING] Unknown action received: $action", $post);
$connection->send('Unknown action: ' . $action);
}
};
$innerHttp->listen();
socket_log("[INFO] Internal HTTP started on port $INTERNAL_PORT");
});
$io->on('connection', function ($socket) {
$query = $socket->handshake['query'] ?? [];
$passengerId = $query['id'] ?? null;
$jwtToken = $query['jwt'] ?? ''; // JWT Token for authentication
$clientIp = $socket->conn->remoteAddress ?? 'Unknown';
if (!$passengerId || empty($jwtToken)) {
socket_log("[SOCKET_REJECTED] Connection rejected (No passenger ID or JWT missing) from IP: $clientIp");
$socket->disconnect();
return;
}
try {
$secretKey = getJwtSecret();
if (empty($secretKey)) {
socket_log("[WARNING] JWT Secret is not configured on the server!");
} else {
$decoded = JWT::decode($jwtToken, new Key($secretKey, 'HS256'));
if ((string)$decoded->sub !== (string)$passengerId || $decoded->role !== 'passenger') {
socket_log("[SOCKET_REJECTED] Connection rejected: Invalid JWT for passenger_id=$passengerId from IP: $clientIp");
$socket->disconnect();
return;
}
}
} catch (\Exception $e) {
socket_log("[SOCKET_REJECTED] Connection rejected: JWT Verification failed -> " . $e->getMessage() . " from IP: $clientIp");
$socket->disconnect();
return;
}
$socket->join('passenger_' . $passengerId);
socket_log("[SOCKET_CONNECTED] Passenger Connected: #$passengerId (IP: $clientIp)");
$socket->on('heartbeat', function ($data) {
// يمكن تفعيل السطر التالي للتأكد من النبضات إذا أردت دقة شديدة، لكنه قد يملأ ملف الـ log
// socket_log("[SOCKET_HEARTBEAT] Received from Passenger #$passengerId");
});
$socket->on('disconnect', function () use ($passengerId, $clientIp) {
socket_log("[SOCKET_DISCONNECTED] Passenger Disconnected: #$passengerId (IP: $clientIp)");
});
});
Worker::runAll();
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -555,8 +555,7 @@ class AppLink {
static String get claimInviteReward => "$server/ride/invitor/claim.php";
static String get updateInvitationCodeFromRegister =>
"$ride/invitor/updateInvitationCodeFromRegister.php";
static String get register_driver_and_car =>
"$auth/driver/register.php";
static String get register_driver_and_car => "$auth/driver/register.php";
static String get updateDriverInvitationDirectly =>
"$ride/invitor/updateDriverInvitationDirectly.php";
static String get updatePassengersInvitation =>
@@ -381,8 +381,11 @@ class NavigationController extends GetxController
Future<void> onStyleLoaded() async {
Log.print("DEBUG: NavigationController.onStyleLoaded called");
isStyleLoaded = true;
// Register the custom icons (car/start/dest) BEFORE marking the style as
// ready. Otherwise a location tick can fire _updateCarMarker() in the gap
// and reference 'car_icon' before it exists in the style → blank marker.
await _loadCustomIcons();
isStyleLoaded = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 300));
@@ -490,8 +493,7 @@ class NavigationController extends GetxController
_isProcessing = true;
try {
currentSpeed =
locSpeed; // Convert m/s to km/h already done by location controller if needed, wait location_controller sends raw speed or km/h? It sends raw speed. So we should * 3.6
// LocationController forwards raw speed in m/s; convert to km/h.
currentSpeed = locSpeed * 3.6;
// Skip if movement is too small
@@ -674,7 +676,21 @@ class NavigationController extends GetxController
}
Future<void> _updateCarMarker() async {
// Car marker is now handled natively by myLocationEnabled: true.
if (myLocation == null || !isStyleLoaded || mapController == null) return;
// Draw the driver's car as a single imperatively-managed puck. It moves on
// every location tick, so it must NOT live in the declarative `markers` set
// (in-place mutation of that Set is invisible to the map widget's diff and
// makes the car freeze or never render). setUserMarker updates one native
// symbol in place instead.
await mapController!.setUserMarker(Marker(
markerId: const MarkerId('car'),
position: myLocation!,
icon: InlqBitmap.fromStyleImage('car_icon'),
anchor: const Offset(0.5, 0.5),
flat: true,
rotation: _smoothedHeading,
zIndex: 100,
));
}
void animateCameraToPosition(LatLng position,
@@ -1053,8 +1069,9 @@ class NavigationController extends GetxController
_finalDestination = destination;
await clearRoute(isNewRoute: true);
// Preserve car marker if it exists
markers = markers.where((m) => m.markerId.value == 'car').toSet();
// The car puck is managed imperatively (setUserMarker), not via this set,
// so start from a clean declarative set holding only origin/destination.
markers = <Marker>{};
markers.add(Marker(
markerId: const MarkerId('destination'),
@@ -46,6 +46,7 @@ class NavigationView extends StatelessWidget {
child: IntaleqMap(
apiKey: Env.mapSaasKey,
onMapCreated: c.onMapCreated,
onStyleLoaded: c.onStyleLoaded,
onLongPress: (pos) => c.onMapLongPressed(Point(0, 0), pos),
onTap: (pos) => c.onMapTapped(Point(0, 0), pos),
markers: c.markers,
+4 -2
View File
@@ -201,7 +201,8 @@ class AppLink {
//=======================Siro Prime===================
static String get initiatePrime => "$server/api/payments/initiate_prime.php";
static String get getPrimeStatus => "$server/api/payments/get_prime_status.php";
static String get getPrimeStatus =>
"$server/api/payments/get_prime_status.php";
static String get getWalletByDriver => "$walletDriver/getWalletByDriver.php";
static String get getDriversWallet => "$walletDriver/get.php";
@@ -396,7 +397,8 @@ class AppLink {
// Endpoint لجلب التسعيرة من السيرفر (Server-Side Pricing)
static String get getPrices => "$server/ride/pricing/get.php";
static String get getCompetitorContext => "$server/api/ride/get_competitor_context.php";
static String get getCompetitorContext =>
"$server/api/ride/get_competitor_context.php";
static String get addRateToDriver => "$server/ride/rate/addRateToDriver.php";
static String get getDriverRate => "$server/ride/rate/getDriverRate.php";
@@ -1889,7 +1889,7 @@ class RideLifecycleController extends GetxController {
var digest = md5.convert(bytes);
String token = digest.toString();
return "https://intaleqapp.com/track/index.php?id=$cleanRideId&token=$token";
return "https://siromove.com/track/index.php?id=$cleanRideId&token=$token";
}
calcualateDistsanceInMetet(LatLng prev, current) async {
@@ -2205,24 +2205,26 @@ class RideLifecycleController extends GetxController {
Future<void> calculateDriverToPassengerRoute(
LatLng driverPos, LatLng passengerPos,
{bool isBeginPhase = false}) async {
if (mapController == null) {
Log.print('⚠️ mapController is null, cannot calculate route via intaleq_maps');
Log.print(
'⚠️ mapController is null, cannot calculate route via intaleq_maps');
return;
}
Log.print('📍 Calculating Driver Route via IntaleqMapController...');
try {
final responseData = await mapController!.getDirections(driverPos, passengerPos);
final responseData =
await mapController!.getDirections(driverPos, passengerPos);
var routeData = responseData['routes'] != null
? responseData['routes'][0]
: responseData;
double durationSecondsRaw = (routeData['trafficAwareDuration'] ?? routeData['duration'] as num).toDouble();
int finalDurationSeconds =
(durationSecondsRaw * kDurationScalar).toInt();
double durationSecondsRaw =
(routeData['trafficAwareDuration'] ?? routeData['duration'] as num)
.toDouble();
int finalDurationSeconds = (durationSecondsRaw * kDurationScalar).toInt();
double distanceMeters = (routeData['distance'] as num).toDouble();
updateDriverRouteMetrics(
@@ -2240,8 +2242,7 @@ class RideLifecycleController extends GetxController {
Log.print(
'✅ Driver Route Info: $minutes min, ${distanceMeters.toInt()} m');
String pointsString =
routeData['points'] ?? routeData['geometry'] ?? "";
String pointsString = routeData['points'] ?? routeData['geometry'] ?? "";
if (pointsString.isNotEmpty) {
List<LatLng> decodedPoints =
await compute(decodePolylineIsolate, pointsString);
@@ -3521,8 +3522,9 @@ class RideLifecycleController extends GetxController {
// 🔥 [Fix Race] كل طلب جديد (attemptCount == 0) يفتح "جيلاً" جديداً؛
// إعادة المحاولات (retries) تتبع نفس الجيل. أي استجابة تصل بعد أن بدأ
// جيل أحدث (تبديل وجهة سريع) تُهمَل ولا تكتب فوق الحالة الحالية.
final int myGeneration =
attemptCount == 0 ? (++_routeRequestGeneration) : (gen ?? _routeRequestGeneration);
final int myGeneration = attemptCount == 0
? (++_routeRequestGeneration)
: (gen ?? _routeRequestGeneration);
if (attemptCount == 0) {
isDrawingRoute = true;
@@ -3616,8 +3618,8 @@ class RideLifecycleController extends GetxController {
if (attemptCount < 2) {
Log.print("🔄 Retrying request (Attempt ${attemptCount + 2})...");
await Future.delayed(const Duration(seconds: 1));
await getDirectionMap(origin, destination, waypoints,
attemptCount + 1, myGeneration);
await getDirectionMap(
origin, destination, waypoints, attemptCount + 1, myGeneration);
return;
} else {
Log.print("❌ All retries failed. Calculating Route is impossible.");
@@ -3798,8 +3800,9 @@ class RideLifecycleController extends GetxController {
}
}
Future<void> _retryProcess(String origin, String dest, List<String> waypoints,
int currentAttempt, [int? gen]) async {
Future<void> _retryProcess(
String origin, String dest, List<String> waypoints, int currentAttempt,
[int? gen]) async {
Log.print(
"🔄 Exception or Error caught. Retrying in 1s... (Attempt ${currentAttempt + 1})");
await Future.delayed(const Duration(seconds: 1));
@@ -4481,7 +4484,7 @@ class RideLifecycleController extends GetxController {
required double dashLengthMeters,
required double gapLengthMeters,
required Color color,
required int width,
required double width,
required String idPrefix,
}) {
final Set<Polyline> result = {};
@@ -407,8 +407,11 @@ class NavigationController extends GetxController
Future<void> onStyleLoaded() async {
Log.print("DEBUG: NavigationController.onStyleLoaded called");
isStyleLoaded = true;
// Register the custom icons (car/start/dest) BEFORE marking the style as
// ready. Otherwise a location tick can fire _updateCarMarker() in the gap
// and reference 'car_icon' before it exists in the style → blank marker.
await _loadCustomIcons();
isStyleLoaded = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 300));
@@ -715,9 +718,12 @@ class NavigationController extends GetxController
}
Future<void> _updateCarMarker() async {
if (myLocation == null || !isStyleLoaded) return;
markers.removeWhere((m) => m.markerId.value == 'car');
markers.add(Marker(
if (myLocation == null || !isStyleLoaded || mapController == null) return;
// The car puck moves on every GPS/animation tick. Driving it through the
// declarative `markers` set means mutating the same Set in place, which the
// map widget's diff can't see (old == new reference) — so the puck would
// freeze or never appear. Update it imperatively as a single native symbol.
await mapController!.setUserMarker(Marker(
markerId: const MarkerId('car'),
position: myLocation!,
icon: InlqBitmap.fromStyleImage('car_icon'),
@@ -1113,8 +1119,9 @@ class NavigationController extends GetxController
_finalDestination = destination;
await clearRoute(isNewRoute: true);
// Preserve car marker if it exists
markers = markers.where((m) => m.markerId.value == 'car').toSet();
// The car puck is managed imperatively (setUserMarker), not via this set,
// so start from a clean declarative set holding only origin/destination.
markers = <Marker>{};
markers.add(Marker(
markerId: const MarkerId('destination'),