Food delivery driver module + masked calls + TURN
This commit is contained in:
+132
-2
@@ -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 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
|
||||
|
||||
Reference in New Issue
Block a user