Update: 2026-08-07 02:10:33
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_leak_detection.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* كشف التسرب خارج المنصة — الرحلة تحصل فعلاً والعمولة لا تصل.
|
||||
*
|
||||
* ── لماذا خادمياً ──
|
||||
* جدول driver_ride_scam يملؤه تطبيق السائق عن نفسه، فنسخة معدّلة لا
|
||||
* ترسل شيئاً. هذا الكرون يستنتج النمط من بيانات لا يملك السائق تزويرها:
|
||||
* سجل القبول في driver_orders، وحالة الرحلة في ride.
|
||||
*
|
||||
* ── النمط ──
|
||||
* قَبِل السائق → أُلغيت الرحلة → ولم تبدأ قط
|
||||
* مرة واحدة قد تكون صدفة. تكرار نفس زوج (سائق، راكب) ليس صدفة.
|
||||
*
|
||||
* ── العقوبة ──
|
||||
* لا حظر. تُكتب درجة الاشتباه في driver:score:$id (حقل leak_penalty)
|
||||
* فتخفض ترتيب السائق في محرك الإسناد — يخسر السبق والرحلات الأقرب
|
||||
* تدريجياً. موجع بلا حرمان، وقابل للتراجع فوراً إن تبيّن الخطأ.
|
||||
* الحالات عالية الثقة تُترك في driver_leak_events لمراجعة بشرية.
|
||||
*
|
||||
* جدولة مقترحة (crontab): كل ساعة.
|
||||
* docker compose exec -T php php /var/www/backend/bot/cron_leak_detection.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php — Redis لا ينشر منافذ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// ── معاملات المعايرة ──
|
||||
const LEAK_WINDOW_DAYS = 30; // نافذة احتساب التكرار
|
||||
const LEAK_SCAN_HOURS = 48; // عمق الفحص في كل مرور (بهامش للتأخر)
|
||||
const LEAK_PENALTY_MAX = 1.0; // سقف العقوبة
|
||||
const LEAK_REVIEW_THRESHOLD = 0.70; // فوقها تستحق مراجعة بشرية
|
||||
|
||||
// الحالات التي تعني "أُلغيت بعد القبول"
|
||||
const LEAK_CANCEL_STATUSES = ['CancelFromDriverAfterApply', 'cancelled_by_passenger'];
|
||||
|
||||
// الحالات التي تعني أن الرحلة بدأت أو اكتملت — وجودها ينفي الاشتباه.
|
||||
// 'arrived' ليست منها عمداً: الوصول ثم الإلغاء هو النمط المشتبه نفسه،
|
||||
// لا نفيه. (عملياً الإلغاء يكتب فوق الحالة، فالفلتر يعمل على النهائية.)
|
||||
const LEAK_STARTED_STATUSES = ['started', 'Begin', 'Finished', 'finished'];
|
||||
|
||||
if (!isset($redisLocation) || !$redisLocation) {
|
||||
fwrite(STDERR, "[leak] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, "[leak] DB unavailable: " . $e->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";
|
||||
@@ -393,6 +393,15 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa
|
||||
$payloadForDriver[6] = (string)$driverId;
|
||||
$payloadForDriver[18] = (string)$driverId;
|
||||
|
||||
// بيانات الراكب (التوكن 9، الهاتف 10، البريد 24) تبقى في الحمولة
|
||||
// بقرار المالك: التطبيق لا يعرضها في صفحة الطلب، ولا تظهر إلا بعد
|
||||
// القبول في صفحة خريطة الراكب. إرسالها مسبقاً يوفّر استعلاماً على
|
||||
// القاعدة عند كل قبول.
|
||||
//
|
||||
// المقايضة المعروفة: الحجب واجهةٌ لا خادم — الحمولة نفسها تصل كل
|
||||
// المرشحين. لذلك يقوم إغلاق التسرب هنا على الكشف والعقوبة (أدناه)
|
||||
// لا على حجب البيانات.
|
||||
|
||||
// وسم العرض للفهرسَين اللذين يقرأهما تطبيق السائق. الفهارس بينهما
|
||||
// قد تكون غير معرَّفة، فنملؤها بفراغ وإلا صارت القائمة متفرقة
|
||||
// (sparse) وخرجت من json_encode كخريطة لا كمصفوفة.
|
||||
|
||||
@@ -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`;
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user