Files
2026-08-09 16:56:13 +03:00

445 lines
25 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 ?? '',
]);
// ═══════════════════════════════════════════════════════════
// ‏الراكب الذي طلب برسالة نصية لا يملك إنترنت: السوكيت لن يصله،
// ‏والإشعار الصامت أعلاه يذهب إلى العدم. الرسالة النصية هي قناته
// ‏الوحيدة لمعرفة أن سائقاً قَبِل، ومن هو، وكيف يعرف سيارته.
// ═══════════════════════════════════════════════════════════
try {
$stmtSms = $con->prepare("
SELECT sender_norm FROM sms_ride_requests
WHERE ride_id = ? AND status = 'dispatched' LIMIT 1
");
$stmtSms->execute([$rideId]);
$smsSender = $stmtSms->fetchColumn();
if ($smsSender) {
require_once __DIR__ . '/../../sms/helpers.php';
$plate = trim((string) ($driverInfo['car_plate'] ?? ''));
$car = trim(($driverInfo['make'] ?? '') . ' ' . ($driverInfo['model'] ?? ''));
$color = trim((string) ($driverInfo['color'] ?? ''));
$lines = ['سيرو: وجدنا لك سائقاً.'];
$lines[] = 'السائق: ' . ($driverInfo['driverName'] ?? '—');
if (!empty($driverInfo['phone'])) $lines[] = 'هاتفه: ' . $driverInfo['phone'];
if ($car !== '' || $color !== '') $lines[] = 'السيارة: ' . trim("$car $color");
if ($plate !== '') $lines[] = 'اللوحة: ' . $plate;
$lines[] = 'سيتصل بك عند وصوله.';
smsEnqueue($con, (string) $smsSender, implode("\n", $lines),
'driver_found', (string) $rideId);
}
} catch (Throwable $eSms) {
// ‏لا نُسقط قبول الرحلة من أجل رسالة: السائق قَبِل فعلاً،
// ‏والرسالة الفائتة أهون من رحلة تُلغى بخطأ.
error_log('[accept_ride] تعذّر تجهيز رسالة SMS للراكب: ' . $eSms->getMessage());
}
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");
}