Update: 2026-08-07 02:10:33

This commit is contained in:
Hamza-Ayed
2026-08-07 02:10:33 +03:00
parent 9830e5f649
commit b492034bad
4 changed files with 270 additions and 5 deletions
+194
View File
@@ -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";