Files
Siro/backend/bot/cron_leak_detection.php

195 lines
9.6 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
/**
* 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";