409 lines
22 KiB
PHP
409 lines
22 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// driver/ride/accept_ride.php
|
||
// PURPOSE : قبول رحلة — ride DB هو المرجع، primary DB يتزامن بعده
|
||
// RACE : Optimistic lock عبر WHERE status IN ('waiting','wait')
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
include "../../connect.php";
|
||
|
||
try {
|
||
$con_ride = Database::get('ride');
|
||
} catch (Exception $e) {
|
||
error_log("[accept_ride] Failed to connect to Ride Database: " . $e->getMessage());
|
||
printFailure("Database connection failed");
|
||
exit;
|
||
}
|
||
|
||
// ── 1. Input & Validation ──────────────────────────────────────
|
||
$rideId = filterRequest("id");
|
||
// Force driver_id from JWT — never trust user-supplied driver_id
|
||
$driverId = $user_id;
|
||
$status = filterRequest("status"); // القيمة التي يرسلها التطبيق: 'accepted'
|
||
$passengerToken = filterRequest("passengerToken");
|
||
$passengerFingerprint = filterRequest("passengerFingerprint");
|
||
$passengerIdValue = filterRequest("passenger_id");
|
||
|
||
if (empty($rideId) || empty($driverId)) {
|
||
printFailure("Missing required parameters");
|
||
exit;
|
||
}
|
||
|
||
// Self-ride validation
|
||
$driverFingerprint = isset($_SERVER['HTTP_X_DEVICE_FP']) ? $_SERVER['HTTP_X_DEVICE_FP'] : '';
|
||
if (!empty($driverFingerprint) && $driverFingerprint === $passengerFingerprint) {
|
||
error_log("[accept_ride] Self-ride attempt blocked. DriverID=$driverId, Fingerprint=$driverFingerprint");
|
||
printFailure("Self-matching is not allowed");
|
||
exit;
|
||
}
|
||
|
||
// status whitelist — لا نقبل قيمة عشوائية من التطبيق
|
||
$allowedStatuses = ['accepted', 'Apply'];
|
||
if (!in_array($status, $allowedStatuses, true)) {
|
||
$status = 'accepted'; // fallback آمن
|
||
}
|
||
|
||
error_log("[accept_ride] DriverID=$driverId attempting RideID=$rideId");
|
||
|
||
try {
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP A0 — تطبيق الإيقاف على الخادم
|
||
// كان الإيقاف بعد ٣ إلغاءات يُكتب في تخزين تطبيق السائق وحده، فيُفكّ
|
||
// بمسح بيانات التطبيق أو إعادة تثبيته. هذه هي البوابة الحقيقية: سائق
|
||
// موقوف لا يستطيع أن يفوز برحلة مهما فعل بتطبيقه.
|
||
// ═══════════════════════════════════════════════════════════
|
||
// الفحص من Redis لا MySQL: هذا المسار يُستدعى على كل محاولة قبول من كل
|
||
// سائق، والإيقاف حالة نادرة قصيرة العمر. مفتاح بـ TTL يجعل انتهاء
|
||
// الإيقاف تلقائياً بلا كرون ولا استعلام تنظيف.
|
||
// MySQL يبقى السجل الدائم للمراجعة الإدارية، ولا يُقرأ هنا.
|
||
try {
|
||
if (isset($redisLocation) && $redisLocation) {
|
||
$blockedUntil = $redisLocation->get("driver:$driverId:blocked");
|
||
if ($blockedUntil) {
|
||
error_log("[accept_ride] DriverID=$driverId موقوف حتى $blockedUntil — رُفض القبول");
|
||
printFailure("Your account is temporarily suspended until $blockedUntil");
|
||
exit;
|
||
}
|
||
|
||
// تجميد سلامة — بلا TTL: لا ينتهي بالوقت بل برفع إنسان له.
|
||
// سائق عليه بلاغ سلامة مفتوح لا يُسند له راكب جديد.
|
||
$safetyHold = $redisLocation->get("driver:$driverId:safety_hold");
|
||
if ($safetyHold) {
|
||
error_log("[accept_ride] DriverID=$driverId مجمّد ببلاغ سلامة #$safetyHold — رُفض القبول");
|
||
printFailure("Your account is under review. Please contact support.");
|
||
exit;
|
||
}
|
||
}
|
||
} catch (Throwable $eBlock) {
|
||
error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP A0b — حارس الحصرية في الإسناد بالدفعات
|
||
//
|
||
// بدون هذا الفحص تبقى الحصرية شكلية: سائق تلقّى العرض سابقاً (أو صاغ
|
||
// الطلب يدوياً) يستطيع القبول خارج دوره فيعود السباق كما كان.
|
||
//
|
||
// وجود المفتاح هو ما يحدد الوضع: البثّ الحر لا يكتبه أصلاً، فالفحص
|
||
// يمرّ بلا أثر — لا حاجة لقراءة DISPATCH_MODE هنا.
|
||
// ═══════════════════════════════════════════════════════════
|
||
try {
|
||
if (isset($redisLocation) && $redisLocation) {
|
||
$currentOffer = $redisLocation->get("ride:$rideId:current_offer");
|
||
if ($currentOffer !== false && $currentOffer !== null
|
||
&& (string) $currentOffer !== (string) $driverId) {
|
||
error_log("[accept_ride] DriverID=$driverId حاول قبول RideID=$rideId"
|
||
. " والعرض الحالي لـ $currentOffer — رُفض");
|
||
printFailure("Ride not available");
|
||
exit;
|
||
}
|
||
}
|
||
} catch (Throwable $eOffer) {
|
||
// فشل الفحص لا يمنع القبول — القفل التفاؤلي في STEP A يبقى الضامن
|
||
// ضد الازدواج، وأسوأ نتيجة هنا هي عودة سلوك البثّ الحر لهذه الرحلة.
|
||
error_log("[accept_ride] تعذّر فحص حصرية العرض: " . $eOffer->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP A — القفل على ride DB (المرجع الأساسي)
|
||
// Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait'
|
||
// السائق الأول الذي يصل يربح — الباقي يجدون rowCount=0
|
||
// ═══════════════════════════════════════════════════════════
|
||
$stmtLock = $con_ride->prepare("
|
||
UPDATE `ride`
|
||
SET `status` = ?,
|
||
`driver_id` = ?,
|
||
`rideTimeStart` = NOW()
|
||
WHERE `id` = ?
|
||
AND `status` IN ('waiting', 'wait')
|
||
");
|
||
$stmtLock->execute([$status, $driverId, $rideId]);
|
||
|
||
if ($stmtLock->rowCount() === 0) {
|
||
// الرحلة غير متاحة — سائق آخر سبق أو الرحلة ألغيت
|
||
error_log("[accept_ride] RideID=$rideId not available for DriverID=$driverId (rowCount=0)");
|
||
printFailure("Ride not available");
|
||
exit;
|
||
}
|
||
|
||
error_log("[accept_ride] ride DB locked. RideID=$rideId → DriverID=$driverId");
|
||
|
||
// عدّاد القبول — بسط معدّل القبول الذي يرتّب به محرك الإسناد.
|
||
// يُعدّ هنا بعد نجاح القفل حصراً: العدّ قبله كان سيحسب للخاسرين في
|
||
// السباق قبولاً لم يحدث.
|
||
try {
|
||
if (isset($redisLocation) && $redisLocation) {
|
||
$redisLocation->hIncrBy("driver:score:$driverId", 'accepts', 1);
|
||
}
|
||
} catch (Throwable $eScore) {
|
||
error_log("[accept_ride] تعذّر تحديث عدّاد القبول: " . $eScore->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP B — تزامن primary DB (بعد نجاح القفل)
|
||
// ═══════════════════════════════════════════════════════════
|
||
try {
|
||
$con->prepare("
|
||
UPDATE `ride`
|
||
SET `driver_id` = ?,
|
||
`status` = ?,
|
||
`rideTimeStart` = NOW()
|
||
WHERE `id` = ?
|
||
")->execute([$driverId, $status, $rideId]);
|
||
error_log("[accept_ride] primary DB synced. RideID=$rideId");
|
||
} catch (PDOException $eSync) {
|
||
// لا نوقف — ride DB هو المرجع
|
||
error_log("[accept_ride] primary DB sync WARNING: " . $eSync->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP B1 — تثبيت مسافة الاقتراب لحظة القبول (في Redis لا MySQL)
|
||
//
|
||
// هذه المسافة أساس تعويض عدم الحضور لاحقاً، ويجب أن تُقاس الآن حصراً:
|
||
// عند الإلغاء يكون السائق واقفاً عند نقطة الالتقاط فتصير صفراً.
|
||
//
|
||
// لكنها قيمة عابرة تُقرأ مرة واحدة عند نهاية الرحلة — فلا تستحق كتابتين
|
||
// في MySQL على أسخن مسار في النظام. تُحفظ في نفس الهاش الذي يستخدمه
|
||
// السوكِت لحالة الرحلة (ride:$id:state)، وتُثبَّت في MySQL كتابةً واحدة
|
||
// مع تحديث الحالة النهائي عند الإلغاء.
|
||
//
|
||
// hSet لا يمسح حقول السوكِت الأخرى في نفس الهاش، وTTL الـ24 ساعة الذي
|
||
// يضبطه السوكِت يغطي هذه الحقول تلقائياً.
|
||
// ═══════════════════════════════════════════════════════════
|
||
try {
|
||
$approachKm = 0.0;
|
||
if (isset($redisLocation) && $redisLocation) {
|
||
$stmtPickup = $con->prepare("SELECT start_location FROM ride WHERE id = ?");
|
||
$stmtPickup->execute([$rideId]);
|
||
[$pickupLat, $pickupLng] = array_pad(
|
||
array_map('trim', explode(',', (string) $stmtPickup->fetchColumn(), 2)), 2, null
|
||
);
|
||
|
||
if (is_numeric($pickupLat) && is_numeric($pickupLng)) {
|
||
$loc = $redisLocation->hGetAll("driver:public:$driverId");
|
||
if (!empty($loc['lat']) && !empty($loc['lng'])) {
|
||
$approachKm = round(getDistanceBetweenPoints(
|
||
(float) $loc['lat'], (float) $loc['lng'],
|
||
(float) $pickupLat, (float) $pickupLng
|
||
), 3);
|
||
}
|
||
}
|
||
|
||
if ($approachKm > 0) {
|
||
$redisLocation->hSet("ride:$rideId:state", 'pickup_approach_km', $approachKm);
|
||
}
|
||
}
|
||
|
||
if ($approachKm <= 0) {
|
||
error_log("[accept_ride] تعذّر قياس مسافة الاقتراب RideID=$rideId"
|
||
. " — التعويض سيحتسب زمن الانتظار وحده");
|
||
}
|
||
} catch (Throwable $eApproach) {
|
||
// قياس التعويض لا يُسقط قبول الرحلة أبداً.
|
||
error_log("[accept_ride] pickupApproachKm WARNING: " . $eApproach->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP C — driver_orders (INSERT أو UPDATE بسطر واحد آمن)
|
||
// ON DUPLICATE KEY يمنع race condition ثانية على هذا الجدول
|
||
// ═══════════════════════════════════════════════════════════
|
||
try {
|
||
$con->prepare("
|
||
INSERT INTO `driver_orders` (`driver_id`, `order_id`, `status`, `created_at`)
|
||
VALUES (?, ?, ?, NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
`driver_id` = VALUES(`driver_id`),
|
||
`status` = VALUES(`status`),
|
||
`created_at` = NOW()
|
||
")->execute([$driverId, $rideId, $status]);
|
||
} catch (PDOException $eOrders) {
|
||
error_log("[accept_ride] driver_orders WARNING: " . $eOrders->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP C.1 — تحديث جدول waitingRides حتى لا تظهر للكباتن الآخرين
|
||
// ═══════════════════════════════════════════════════════════
|
||
try {
|
||
$con->prepare("UPDATE `waitingRides` SET `status` = 'Apply' WHERE `id` = ?")->execute([$rideId]);
|
||
} catch (PDOException $eWaiting) {
|
||
error_log("[accept_ride] waitingRides WARNING: " . $eWaiting->getMessage());
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP D — جلب بيانات السائق للراكب
|
||
// ═══════════════════════════════════════════════════════════
|
||
$driverInfo = [];
|
||
|
||
$stmtDriver = $con->prepare("
|
||
SELECT
|
||
d.id AS driver_id,
|
||
d.first_name,
|
||
d.last_name,
|
||
d.gender,
|
||
d.phone,
|
||
c.make,
|
||
c.model,
|
||
c.car_plate,
|
||
c.year,
|
||
c.color,
|
||
c.color_hex,
|
||
(SELECT ROUND(AVG(rating), 2) FROM ratingDriver WHERE driver_id = d.id) AS ratingDriver,
|
||
(SELECT COUNT(*) FROM ratingDriver WHERE driver_id = d.id) AS ratingCount,
|
||
(SELECT COUNT(*) FROM ride WHERE driver_id = d.id AND status IN ('Finished', 'finished')) AS completedRides,
|
||
dt.token
|
||
FROM driver d
|
||
LEFT JOIN CarRegistration c ON c.driverID = d.id
|
||
LEFT JOIN driverToken dt ON dt.captain_id = d.id
|
||
WHERE d.id = ?
|
||
LIMIT 1
|
||
");
|
||
$stmtDriver->execute([$driverId]);
|
||
$driverRaw = $stmtDriver->fetch(PDO::FETCH_ASSOC);
|
||
|
||
if ($driverRaw) {
|
||
$encryptedFields = ['first_name', 'last_name', 'gender', 'phone', 'car_plate', 'token'];
|
||
foreach ($driverRaw as $key => $value) {
|
||
if (in_array($key, $encryptedFields, true) && !empty($value)) {
|
||
$decrypted = false;
|
||
try {
|
||
$decrypted = $encryptionHelper->decryptData($value);
|
||
} catch (\Throwable $e) {
|
||
$decrypted = false;
|
||
}
|
||
$driverInfo[$key] = ($decrypted !== false && $decrypted !== null && $decrypted !== '') ? $decrypted : $value;
|
||
} else {
|
||
$driverInfo[$key] = $value;
|
||
}
|
||
}
|
||
$driverInfo['driverName'] = trim(($driverInfo['first_name'] ?? '') . ' ' . ($driverInfo['last_name'] ?? ''));
|
||
if (empty($driverInfo['driverName']) && !empty($driverInfo['phone'])) {
|
||
$driverInfo['driverName'] = $driverInfo['phone'];
|
||
}
|
||
$driverInfo['ratingDriver'] = !empty($driverInfo['ratingDriver']) ? (string)$driverInfo['ratingDriver'] : "5.0";
|
||
$ratingValue = (float) $driverInfo['ratingDriver'];
|
||
$ratingCount = (int) ($driverInfo['ratingCount'] ?? 0);
|
||
$completedRides = (int) ($driverInfo['completedRides'] ?? 0);
|
||
if ($ratingValue >= 4.8 && $ratingCount >= 50 && $completedRides >= 100) {
|
||
$driverInfo['driverTier'] = 'Professional driver';
|
||
} elseif ($ratingValue >= 4.5 && $ratingCount >= 15 && $completedRides >= 30) {
|
||
$driverInfo['driverTier'] = 'Trusted driver';
|
||
} else {
|
||
$driverInfo['driverTier'] = 'Verified driver';
|
||
}
|
||
|
||
if (isset($redisLocation) && $redisLocation) {
|
||
try {
|
||
// المفتاح الصحيح driver:public — و driver:location لا يُكتب في أي
|
||
// مكان إطلاقاً فكانت القراءة ترجع فارغة دائماً، فيصل الراكب بلا
|
||
// إحداثيات أولية للسائق. الكاتب driver_socket.php في معالج
|
||
// الدفعات (hmset على driver:profile و driver:public معاً، بحقول
|
||
// lat/lng/heading نفسها، وTTL أربعٍ وعشرين ساعة للعام).
|
||
$driverLoc = $redisLocation->hGetAll("driver:public:$driverId");
|
||
if (!empty($driverLoc)) {
|
||
$driverInfo['lat'] = (float)($driverLoc['lat'] ?? 0);
|
||
$driverInfo['lng'] = (float)($driverLoc['lng'] ?? 0);
|
||
$driverInfo['heading'] = (float)($driverLoc['heading'] ?? 0);
|
||
}
|
||
} catch (Exception $eLoc) {
|
||
// Ignore location fetch error
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP E — جلب passenger_id وإرسال الإشعارات
|
||
// ═══════════════════════════════════════════════════════════
|
||
if (empty($passengerIdValue)) {
|
||
$passengerId = $con->prepare("SELECT passenger_id FROM ride WHERE id = ? LIMIT 1");
|
||
$passengerId->execute([$rideId]);
|
||
$passengerIdValue = $passengerId->fetchColumn();
|
||
}
|
||
|
||
// 🆕 نحلّ توكن الراكب من القاعدة دائماً ولا نعتمد على ما يرسله التطبيق:
|
||
// • مسار الـ dispatch/FCM يرسل التوكن سليماً (نص صريح من add_ride.php).
|
||
// • مسار "سوق الرحلات" يرسله كما خرج من getRideWaiting.php أي **مشفّراً**
|
||
// (جدول tokens يخزّنه مشفّراً — انظر ride/firebase/addToken.php)، فكان
|
||
// FCM يرفضه بـ 400 ولا يصل الراكب أي إشعار قبول.
|
||
// القيمة القادمة من العميل تبقى fallback أخيراً فقط.
|
||
$passengerTokenFromClient = $passengerToken;
|
||
$passengerToken = '';
|
||
if ($passengerIdValue) {
|
||
try {
|
||
$stmtTok = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? LIMIT 1");
|
||
$stmtTok->execute([$passengerIdValue]);
|
||
$rawPassengerToken = $stmtTok->fetchColumn();
|
||
if (!empty($rawPassengerToken)) {
|
||
$decryptedToken = false;
|
||
try {
|
||
$decryptedToken = $encryptionHelper->decryptData($rawPassengerToken);
|
||
} catch (\Throwable $eTok) {
|
||
$decryptedToken = false;
|
||
}
|
||
$passengerToken = ($decryptedToken !== false && $decryptedToken !== null && $decryptedToken !== '')
|
||
? trim($decryptedToken)
|
||
: $rawPassengerToken;
|
||
}
|
||
} catch (PDOException $eTok) {
|
||
error_log("[accept_ride] passenger token lookup WARNING: " . $eTok->getMessage());
|
||
}
|
||
}
|
||
if (empty($passengerToken)) {
|
||
$passengerToken = $passengerTokenFromClient;
|
||
}
|
||
|
||
if ($passengerIdValue) {
|
||
// Socket — real-time update على خريطة الراكب
|
||
if (function_exists('notifyPassengerOnRideServer')) {
|
||
notifyPassengerOnRideServer($passengerIdValue, [
|
||
'status' => 'accepted',
|
||
'ride_id' => $rideId,
|
||
'driver_id' => $driverId,
|
||
'driver_info' => $driverInfo,
|
||
]);
|
||
}
|
||
|
||
// FCM — push notification صامت
|
||
if (!empty($passengerToken)) {
|
||
sendFCM_Internal(
|
||
$passengerToken,
|
||
"", // تفريغ العنوان للإرسال الصامت
|
||
"", // تفريغ المحتوى للإرسال الصامت
|
||
['ride_id' => (string) $rideId, 'driver_info' => $driverInfo, 'status' => 'accepted'],
|
||
"Accepted Ride",
|
||
false
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP F — تنظيف السوق + Cache ride state (أبلغ location server)
|
||
// ═══════════════════════════════════════════════════════════
|
||
sendToLocationServer('ride_taken_event', [
|
||
'ride_id' => $rideId,
|
||
'taken_by_driver_id' => $driverId,
|
||
]);
|
||
|
||
// 🆕 Cache ride state in Redis
|
||
sendToLocationServer('update_ride_state', [
|
||
'ride_id' => $rideId,
|
||
'status' => $status,
|
||
'driver_id' => $driverId,
|
||
'passenger_id' => $passengerIdValue ?? '',
|
||
]);
|
||
|
||
error_log("[accept_ride] SUCCESS. RideID=$rideId accepted by DriverID=$driverId");
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// STEP G — رد النجاح للسائق (نفس بنية الرد القديمة)
|
||
// ═══════════════════════════════════════════════════════════
|
||
echo json_encode([
|
||
"status" => "success",
|
||
"message" => "Ride Accepted",
|
||
"data" => $driverInfo,
|
||
]);
|
||
|
||
} catch (PDOException $e) {
|
||
error_log("[accept_ride] CRITICAL: " . $e->getMessage());
|
||
printFailure("Server error");
|
||
}
|