From b492034bad72ee41204fbe6ebd27e0ec88aefe6f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 02:10:33 +0300 Subject: [PATCH] Update: 2026-08-07 02:10:33 --- backend/bot/cron_leak_detection.php | 194 ++++++++++++++++++ backend/functions.php | 9 + .../migrations/2026_08_07_leak_detection.sql | 50 +++++ backend/ride/dispatch/dispatch_helper.php | 22 +- 4 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 backend/bot/cron_leak_detection.php create mode 100644 backend/migrations/2026_08_07_leak_detection.sql diff --git a/backend/bot/cron_leak_detection.php b/backend/bot/cron_leak_detection.php new file mode 100644 index 00000000..8084eb02 --- /dev/null +++ b/backend/bot/cron_leak_detection.php @@ -0,0 +1,194 @@ +getMessage() . "\n"); + exit(1); +} + +// ═══════════════════════════════════════════════════════════════ +// ١) الرحلات المشتبهة في النافذة الأخيرة +// +// ‏ملاحظة: cancel_ride_by_driver.php يصفّر ride.driver_id عند الإلغاء، +// ‏فالسائق لا يُعرف من جدول ride. driver_orders هو من يحتفظ بالربط. +// ═══════════════════════════════════════════════════════════════ +$cancelPlaceholders = implode(',', array_fill(0, count(LEAK_CANCEL_STATUSES), '?')); +$startedPlaceholders = implode(',', array_fill(0, count(LEAK_STARTED_STATUSES), '?')); + +$sql = " + SELECT dord.driver_id, dord.order_id AS ride_id, dord.status AS cancel_status, + r.passenger_id + FROM driver_orders dord + JOIN ride r ON r.id = dord.order_id + WHERE dord.status IN ($cancelPlaceholders) + AND dord.created_at >= NOW() - INTERVAL " . LEAK_SCAN_HOURS . " HOUR + AND r.status NOT IN ($startedPlaceholders) + AND dord.driver_id <> '0' + AND r.passenger_id <> '' +"; + +try { + $stmt = $con->prepare($sql); + $stmt->execute(array_merge(LEAK_CANCEL_STATUSES, LEAK_STARTED_STATUSES)); + $suspects = $stmt->fetchAll(PDO::FETCH_ASSOC); +} catch (PDOException $e) { + fwrite(STDERR, "[leak] فشل الفحص: " . $e->getMessage() . "\n"); + exit(1); +} + +// ═══════════════════════════════════════════════════════════════ +// ٢) تقييم كل حالة وتسجيلها +// ═══════════════════════════════════════════════════════════════ +$pairStmt = $con->prepare(" + SELECT COUNT(*) FROM driver_leak_events + WHERE driver_id = ? AND passenger_id = ? + AND created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY +"); + +$appStmt = $con->prepare(" + SELECT COUNT(*) FROM driver_ride_scam + WHERE driverID = ? AND rideID = ? +"); + +$insertStmt = $con->prepare(" + INSERT IGNORE INTO driver_leak_events + (driver_id, passenger_id, ride_id, cancel_status, pair_repeat, app_reported, confidence) + VALUES (?, ?, ?, ?, ?, ?, ?) +"); + +$inserted = 0; +$flagged = 0; + +foreach ($suspects as $s) { + try { + $pairStmt->execute([$s['driver_id'], $s['passenger_id']]); + $pairRepeat = ((int) $pairStmt->fetchColumn()) + 1; + + $appStmt->execute([$s['driver_id'], $s['ride_id']]); + $appReported = ((int) $appStmt->fetchColumn()) > 0 ? 1 : 0; + + // ── الثقة ── + // ‏حالة منفردة ضعيفة عمداً: الإلغاء بعد القبول له أسباب مشروعة + // ‏كثيرة (عطل، خطأ في العنوان، راكب غيّر رأيه). التكرار مع نفس + // ‏الراكب هو ما يحوّلها من حادثة إلى نمط. + $confidence = 0.25; + if ($pairRepeat >= 2) $confidence = 0.60; + if ($pairRepeat >= 3) $confidence = 0.85; + + // ‏بلاغ التطبيق (اتصل ثم أُلغيت) يرفع الثقة ولا يصنعها وحده. + if ($appReported) $confidence = min(1.0, $confidence + 0.15); + + $insertStmt->execute([ + $s['driver_id'], $s['passenger_id'], $s['ride_id'], + $s['cancel_status'], $pairRepeat, $appReported, round($confidence, 3), + ]); + + if ($insertStmt->rowCount() > 0) { + $inserted++; + if ($confidence >= LEAK_REVIEW_THRESHOLD) $flagged++; + } + } catch (PDOException $e) { + fwrite(STDERR, "[leak] خطأ على الرحلة {$s['ride_id']}: " . $e->getMessage() . "\n"); + } +} + +// ═══════════════════════════════════════════════════════════════ +// ٣) تحويل السجل إلى عقوبة في محرك الإسناد +// +// ‏العقوبة تُحسب من مجموع ثقة أحداث السائق في النافذة، لا من عددها — +// ‏فحالتان ضعيفتان لا تساويان حالة واحدة مؤكدة. +// ═══════════════════════════════════════════════════════════════ +$penalized = 0; +try { + $scoreStmt = $con->prepare(" + SELECT driver_id, SUM(confidence) AS total + FROM driver_leak_events + WHERE created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY + GROUP BY driver_id + "); + $scoreStmt->execute(); + + foreach ($scoreStmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + // ‏يُشبَع عند مجموع 3 — أي ثلاث حالات مؤكدة تقريباً = العقوبة كاملة. + $penalty = min(LEAK_PENALTY_MAX, ((float) $row['total']) / 3.0); + $redisLocation->hSet("driver:score:{$row['driver_id']}", 'leak_penalty', round($penalty, 3)); + $penalized++; + } +} catch (Throwable $e) { + fwrite(STDERR, "[leak] تعذّر تطبيق العقوبات: " . $e->getMessage() . "\n"); +} + +// ‏تصفير عقوبة من خرج من النافذة: بلا هذا تبقى العقوبة أبدية في Redis +// ‏حتى لو نظُف سجل السائق تماماً — أي عقوبة بلا انتهاء. +try { + $iterator = null; + $redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY); + $activeStmt = $con->prepare(" + SELECT DISTINCT driver_id FROM driver_leak_events + WHERE created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY + "); + $activeStmt->execute(); + $active = array_flip($activeStmt->fetchAll(PDO::FETCH_COLUMN)); + + while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) { + foreach ($keys as $key) { + $driverId = substr($key, strlen('driver:score:')); + if ($driverId === '' || isset($active[$driverId])) continue; + if ($redisLocation->hExists($key, 'leak_penalty')) { + $redisLocation->hDel($key, 'leak_penalty'); + } + } + } +} catch (Throwable $e) { + fwrite(STDERR, "[leak] تعذّر تنظيف العقوبات المنتهية: " . $e->getMessage() . "\n"); +} + +echo "[leak] فُحص " . count($suspects) . " حالة، سُجّل $inserted جديدة،" + . " منها $flagged تستحق مراجعة بشرية. عوقب $penalized سائق.\n"; diff --git a/backend/functions.php b/backend/functions.php index 76fcd385..a6904411 100644 --- a/backend/functions.php +++ b/backend/functions.php @@ -393,6 +393,15 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa $payloadForDriver[6] = (string)$driverId; $payloadForDriver[18] = (string)$driverId; + // ‏بيانات الراكب (التوكن 9، الهاتف 10، البريد 24) تبقى في الحمولة + // ‏بقرار المالك: التطبيق لا يعرضها في صفحة الطلب، ولا تظهر إلا بعد + // ‏القبول في صفحة خريطة الراكب. إرسالها مسبقاً يوفّر استعلاماً على + // ‏القاعدة عند كل قبول. + // + // ‏المقايضة المعروفة: الحجب واجهةٌ لا خادم — الحمولة نفسها تصل كل + // ‏المرشحين. لذلك يقوم إغلاق التسرب هنا على الكشف والعقوبة (أدناه) + // ‏لا على حجب البيانات. + // ‏وسم العرض للفهرسَين اللذين يقرأهما تطبيق السائق. الفهارس بينهما // ‏قد تكون غير معرَّفة، فنملؤها بفراغ وإلا صارت القائمة متفرقة // ‏(sparse) وخرجت من json_encode كخريطة لا كمصفوفة. diff --git a/backend/migrations/2026_08_07_leak_detection.sql b/backend/migrations/2026_08_07_leak_detection.sql new file mode 100644 index 00000000..678f29cd --- /dev/null +++ b/backend/migrations/2026_08_07_leak_detection.sql @@ -0,0 +1,50 @@ +-- 2026_08_07_leak_detection.sql +-- +-- ‏كشف التسرب خارج المنصة — سجل خادمي مستقل عن بلاغ التطبيق. +-- +-- ‏الموجود اليوم: جدول driver_ride_scam يملؤه **تطبيق السائق عن نفسه** +-- ‏(map_driver_controller.dart:823). نسخة معدّلة من التطبيق ببساطة لا +-- ‏ترسل البلاغ، فالكشف يعتمد على تعاون من نريد كشفه. ويبقى مفيداً كإشارة +-- ‏إضافية، لذلك لا نلمسه. +-- +-- ‏الجديد: الخادم يستنتج النمط بنفسه من بيانات لا يملك السائق تزويرها — +-- ‏قَبِل، ثم أُلغيت، ولم تبدأ الرحلة قط. وتكرار نفس زوج (سائق، راكب) هو +-- ‏الإشارة عالية الثقة: صدفةً مرة، ونمطاً مرتين. +-- +-- ⚠️ ‏يُنفَّذ على قاعدة primary (jorSiroDB) وحدها. + +CREATE TABLE IF NOT EXISTS `driver_leak_events` ( + `id` INT NOT NULL AUTO_INCREMENT, + `driver_id` VARCHAR(100) NOT NULL, + `passenger_id` VARCHAR(100) NOT NULL, + `ride_id` VARCHAR(20) NOT NULL, + `cancel_status` VARCHAR(40) NOT NULL + COMMENT 'الحالة التي انتهت بها الرحلة (CancelFromDriverAfterApply / cancelled_by_passenger)', + `pair_repeat` SMALLINT UNSIGNED NOT NULL DEFAULT 1 + COMMENT 'كم مرة تكرر نفس زوج (سائق، راكب) بهذا النمط خلال النافذة', + `app_reported` TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'هل بلّغ تطبيق السائق أيضاً (driver_ride_scam)؟ تطابقهما يرفع الثقة', + `confidence` DECIMAL(4,3) NOT NULL DEFAULT 0 + COMMENT 'درجة الثقة 0..1 — يحسبها bot/cron_leak_detection.php', + `reviewed_by` VARCHAR(100) NULL DEFAULT NULL + COMMENT 'معرّف الإداري الذي راجع الحالة. NULL = لم تُراجع بعد', + `review_note` VARCHAR(255) NULL DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + -- ‏يمنع الكرون من تسجيل نفس الرحلة مرتين عند كل مرور + UNIQUE KEY `uniq_ride` (`ride_id`), + KEY `idx_driver` (`driver_id`, `created_at`), + KEY `idx_pair` (`driver_id`, `passenger_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='أحداث تسرب مشتبهة يستنتجها الخادم — لا يبلّغ عنها التطبيق'; + +-- ‏driver_orders يُقرأ في هذا الكشف بالانضمام على order_id و driver_id، +-- ‏وهو اليوم بلا أي فهرس غير المفتاح الأساسي (id). الفحص كان سيمسح +-- ‏الجدول كاملاً عند كل مرور. +ALTER TABLE `driver_orders` + ADD INDEX `idx_order` (`order_id`), + ADD INDEX `idx_driver_created` (`driver_id`, `created_at`); + +-- ‏للتراجع: +-- DROP TABLE `driver_leak_events`; +-- ALTER TABLE `driver_orders` DROP INDEX `idx_order`, DROP INDEX `idx_driver_created`; diff --git a/backend/ride/dispatch/dispatch_helper.php b/backend/ride/dispatch/dispatch_helper.php index 8a1055a3..e2e15a64 100644 --- a/backend/ride/dispatch/dispatch_helper.php +++ b/backend/ride/dispatch/dispatch_helper.php @@ -104,14 +104,25 @@ function dispatchScoreDriver(array $driver, array $signals): float // ‏توافقاً مؤكداً لا مجرد احتمال. $destinationFit = !empty($driver['has_destination']) ? 1.0 : 0.5; - return round( - (0.40 * $proximity) + $score = (0.40 * $proximity) + (0.20 * $acceptRate) + (0.15 * $rating) + (0.15 * $idle) - + (0.10 * $destinationFit), - 4 - ); + + (0.10 * $destinationFit); + + // ── عقوبة التسرب خارج المنصة ── + // ‏يحسبها bot/cron_leak_detection.php من نمط: قَبِل ← أُلغيت ← لم تبدأ، + // ‏مع تكرار نفس زوج (سائق، راكب). ليست حظراً بل خفض أولوية: السائق + // ‏يخسر السبق والرحلات الأقرب تدريجياً — موجع بلا حرمان، ويتراجع من + // ‏نفسه حين ينظف سجله لأن الكرون يحذف الحقل خارج النافذة. + // + // ‏تُطرح من الدرجة لا تُضرب فيها: الضرب كان سيبقي السائق الأقرب + // ‏متقدّماً رغم عقوبته، والطرح يزيحه فعلاً عن رأس القائمة. + $leakPenalty = isset($signals['leak_penalty']) + ? max(0.0, min(1.0, (float) $signals['leak_penalty'])) + : 0.0; + + return round(max(0.0, $score - (0.35 * $leakPenalty)), 4); } /** @@ -146,6 +157,7 @@ function dispatchReadSignals($redisLocation, array $driverIds): array 'idle_seconds' => isset($row['last_ride_end']) ? max(0, time() - (int) $row['last_ride_end']) : 0, + 'leak_penalty' => $row['leak_penalty'] ?? null, ]; } } catch (Throwable $e) {