Food delivery driver module + masked calls + TURN

This commit is contained in:
Hamza-Ayed
2026-08-04 01:50:50 +03:00
parent 04c214bdf6
commit f01e408ba6
35 changed files with 3029 additions and 212 deletions
+132 -2
View File
@@ -179,6 +179,91 @@ function foodSendNotificationToPassenger(string $passengerId, string $title, str
}
}
// ============================================================
// قناة اتصال مقنّعة بين السائق والزبون (WebRTC — بلا أرقام هواتف)
// ------------------------------------------------------------
// لا نكشف رقم أي طرف للآخر إطلاقاً: خادم الإشارات (Node) يفتح جلسة
// بمعرّف عشوائي قصير العمر، والطرفان ينضمّان إليها بـ session_id فقط.
// نستعمل نفس بنية مكالمات الرحلات (VOICE_CALL_SERVER_URL) لكن بمرجع
// جلسة مُسمّى food_{order_id} حتى تبقى سجلات الطعام مميّزة عن الرحلات.
// ============================================================
// حد إساءة الاستخدام: عدد محاولات فتح جلسة لكل طلب/طرف خلال ساعتين
const FOOD_CALL_MAX_PER_ORDER = 10;
function foodCallQuotaExceeded(int $orderId, string $callerRole): bool
{
global $redis;
if (!$redis) return false;
$key = "food:call_quota:{$orderId}:{$callerRole}";
$count = (int)$redis->incr($key);
if ($count === 1) $redis->expire($key, 7200);
if ($count > FOOD_CALL_MAX_PER_ORDER) {
appLog("[FOOD][CALL] quota exceeded order=$orderId role=$callerRole", 'WARNING');
return true;
}
return false;
}
/**
* يفتح جلسة مكالمة على خادم الإشارات ويعيد session_id.
* يعيد null عند أي فشل — المُنادي هو من يقرر رسالة الخطأ للمستخدم.
*/
function foodCreateCallSession(int $orderId, string $courierId, string $passengerId): ?array
{
$url = (getenv('VOICE_CALL_SERVER_URL') ?: 'https://calls.intaleqapp.com') . '/sessions';
$apiKey = getenv('VOICE_CALL_API_KEY') ?: '';
if (!$apiKey) {
appLog('[FOOD][CALL] VOICE_CALL_API_KEY missing — cannot create session', 'ERROR');
return null;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => json_encode([
// خادم الإشارات يعامل ride_id كمعرّف نصّي مبهم — نُميّز طلبات الطعام
'ride_id' => 'food_' . $orderId,
'driver_id' => $courierId,
'passenger_id' => $passengerId,
]),
CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
appLog("[FOOD][CALL] signaling server failed (HTTP $httpCode): $result", 'ERROR');
return null;
}
$data = json_decode((string)$result, true);
if (!isset($data['session_id'])) {
appLog('[FOOD][CALL] invalid response schema from signaling server', 'ERROR');
return null;
}
return [
'session_id' => (string)$data['session_id'],
'expires_in' => (int)($data['expires_in'] ?? 60),
];
}
// المكالمة مسموحة فقط داخل النافذة التشغيلية للطلب — بعد التسليم تُقفل القناة
function foodOrderAllowsCall(array $order): bool
{
return in_array($order['status'], ['courier_assigned', 'picked_up'], true)
&& !empty($order['courier_id']);
}
// ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string
@@ -433,11 +518,56 @@ function foodOfferOrderToCourier(int $orderId, string $courierId): void
"INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
)->execute([$orderId, $courierId]);
// العرض يُدفع كاملاً عبر السوكيت: شاشة العرض عند السائق تُبنى من هذه الحمولة
// مباشرة بلا نداء HTTP إضافي (مهلة العرض 20 ثانية لا تحتمل round-trip زائد).
$offer = foodBuildCourierOfferPayload($orderId);
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
// والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]);
// والتطبيق يعتمد أيضاً على courier/pending_offers.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', array_merge(
['order_id' => $orderId, 'courier_id' => $courierId, 'offer_ttl_seconds' => 20],
$offer
));
}
// حمولة عرض التوصيل — مصدر واحد يستخدمه السوكيت و pending_offers.php معاً
// حتى لا تختلف الحقول بين المسار اللحظي ومسار الاحتياط.
function foodBuildCourierOfferPayload(int $orderId): array
{
$con = Database::get('food');
$st = $con->prepare(
"SELECT o.id, o.merchant_id, o.delivery_fee, o.grand_total, o.payment_method,
o.delivery_lat, o.delivery_lng, o.items_total, o.created_at,
m.name_ar AS merchant_name_ar, m.address AS merchant_address,
m.latitude AS merchant_lat, m.longitude AS merchant_lng,
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.id = ? LIMIT 1"
);
$st->execute([$orderId]);
$row = $st->fetch();
if (!$row) return [];
// العنوان النصّي للزبون لا يُرسل في العرض — يظهر فقط بعد القبول (active.php).
// نرسل الإحداثيات فقط لحساب المسافة/الاتجاه في شاشة العرض.
return [
'merchant_id' => (int)$row['merchant_id'],
'merchant_name_ar' => $row['merchant_name_ar'],
'merchant_address' => $row['merchant_address'],
'merchant_lat' => (float)$row['merchant_lat'],
'merchant_lng' => (float)$row['merchant_lng'],
'delivery_lat' => (float)$row['delivery_lat'],
'delivery_lng' => (float)$row['delivery_lng'],
'delivery_fee' => (int)$row['delivery_fee'],
'items_count' => (int)$row['items_count'],
'payment_method' => $row['payment_method'],
// نقداً: السائق سيحصّل هذا المبلغ من الزبون — معلومة قرار أساسية قبل القبول
'cash_to_collect' => $row['payment_method'] === 'cash' ? (int)$row['grand_total'] : 0,
'order_created_at' => $row['created_at'],
];
}
// SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)