Update: 2026-07-09 04:13:14

This commit is contained in:
Hamza-Ayed
2026-07-09 04:13:14 +03:00
parent 70718946f5
commit a526dae042
9 changed files with 198 additions and 42 deletions
+1
View File
@@ -17,6 +17,7 @@ class RateLimiter
'api' => ['requests' => 120, 'window' => 60], // 120 طلب / دقيقة
'ride' => ['requests' => 30, 'window' => 60], // 30 طلب / دقيقة
'upload' => ['requests' => 10, 'window' => 300], // 10 رفع / 5 دقائق
'complaint' => ['requests' => 5, 'window' => 600], // 5 شكاوى / 10 دقائق (كل شكوى تستدعي Gemini + واتساب)
];
public function __construct(?Redis $redis)
+7
View File
@@ -5,6 +5,13 @@
// ! تأكد من أن هذا المسار صحيح بالنسبة لهيكل مشروعك
require_once __DIR__ . '/../../connect.php';
// 🔥 [Fix Rate Limit] كل شكوى تستدعي Gemini API (مدفوع) وترسل رسالة واتساب
// لخدمة العملاء — بدون حد كانت قابلة للإغراق (spam) من أي مستخدم مسجّل.
global $limiter, $user_id;
if (isset($limiter)) {
$limiter->enforce(RateLimiter::identifier($user_id), 'complaint');
}
// --- إعدادات النظام ---
$geminiApiKey = getenv("GEMINI_API_KEY");
$customerServiceWhatsapp = getenv("SERVICE_PHONE1"); // يُفترض أن هذا مُعرّف في connect.php أو متغيرات البيئة
+69 -22
View File
@@ -1,35 +1,82 @@
<?php
require_once __DIR__ . '/../../connect.php';
// Force passenger_id from JWT — never trust user-supplied passenger_id
if ($role !== 'passenger') {
jsonError("Only passengers can submit ratings");
// هذا المسار: السائق يقيّم الراكب — المُرسِل هو السائق، وليس الراكب.
// نفرض هوية السائق من التوكن (driverID) بدل الثقة بأي قيمة من العميل،
// و passenger_id هو "الهدف" (الراكب المُقيَّم) القادم من الطلب لكن يجب
// التحقق أنه فعلاً راكب هذه الرحلة قبل قبوله (لمنع IDOR).
if ($role !== 'driver') {
jsonError("Only drivers can rate passengers");
exit;
}
$passenger_id = $user_id;
$driverID = filterRequest("driverID");
$driverID = $user_id;
$passenger_id = filterRequest("passenger_id");
$rideId = filterRequest("rideId");
$rating = filterRequest("rating");
$comment = filterRequest("comment");
$sql = "INSERT INTO `ratingPassenger` (
`passenger_id`, `driverID`, `rideId`, `rating`, `comment`
) VALUES (
:passenger_id, :driverID, :rideId, :rating, :comment
)";
try {
if (empty($passenger_id) || empty($rideId) || empty($rating)) {
throw new Exception("Required fields are missing");
}
$stmt = $con->prepare($sql);
$stmt->bindParam(':passenger_id', $passenger_id);
$stmt->bindParam(':driverID', $driverID);
$stmt->bindParam(':rideId', $rideId);
$stmt->bindParam(':rating', $rating);
$stmt->bindParam(':comment', $comment);
// 🔥 التحقق من ملكية الرحلة: يجب أن تخص هذا السائق وهذا الراكب فعلاً،
// وأن تكون منتهية، قبل قبول التقييم — يمنع تقييم رحلات عشوائية (IDOR).
$stmtRide = $con->prepare("SELECT driver_id, passenger_id, status FROM ride WHERE id = ?");
$stmtRide->execute([$rideId]);
$ride = $stmtRide->fetch(PDO::FETCH_ASSOC);
$stmt->execute();
if (!$ride) {
jsonError("Ride not found");
exit;
}
if ((string)$ride['driver_id'] !== (string)$driverID) {
jsonError("This ride does not belong to you");
exit;
}
if ((string)$ride['passenger_id'] !== (string)$passenger_id) {
jsonError("Passenger does not match this ride");
exit;
}
if ($ride['status'] !== 'Finished') {
jsonError("Ride must be finished before rating");
exit;
}
if ($stmt->rowCount() > 0) {
jsonSuccess(null, "Rate inserted successfully");
} else {
jsonError("Failed to save rating information");
// 🔥 منع تكرار التقييم لنفس الرحلة من نفس السائق
$stmtDup = $con->prepare("SELECT COUNT(*) FROM `ratingPassenger` WHERE rideId = ? AND driverID = ?");
$stmtDup->execute([$rideId, $driverID]);
if ($stmtDup->fetchColumn() > 0) {
jsonError("This ride has already been rated");
exit;
}
$sql = "INSERT INTO `ratingPassenger` (
`passenger_id`, `driverID`, `rideId`, `rating`, `comment`
) VALUES (
:passenger_id, :driverID, :rideId, :rating, :comment
)";
$stmt = $con->prepare($sql);
$stmt->bindParam(':passenger_id', $passenger_id);
$stmt->bindParam(':driverID', $driverID);
$stmt->bindParam(':rideId', $rideId);
$stmt->bindParam(':rating', $rating);
$stmt->bindParam(':comment', $comment);
$stmt->execute();
if ($stmt->rowCount() > 0) {
jsonSuccess(null, "Rate inserted successfully");
} else {
jsonError("Failed to save rating information");
}
} catch (PDOException $e) {
error_log("[rate/add] DB Error: " . $e->getMessage() . " | RideID: $rideId");
jsonError("Database Error: Could not save rating");
} catch (Exception $e) {
error_log("[rate/add] General Error: " . $e->getMessage());
jsonError("Error: Could not save rating");
}
?>
?>
+31
View File
@@ -19,6 +19,37 @@ try {
throw new Exception("Required fields are missing");
}
// 🔥 التحقق من ملكية الرحلة: يجب أن تخص هذا الراكب وهذا السائق فعلاً،
// وأن تكون منتهية، قبل قبول التقييم — يمنع تقييم رحلات عشوائية (IDOR).
$stmtRide = $con->prepare("SELECT driver_id, passenger_id, status FROM ride WHERE id = ?");
$stmtRide->execute([$ride_id]);
$ride = $stmtRide->fetch(PDO::FETCH_ASSOC);
if (!$ride) {
jsonError("Ride not found");
exit;
}
if ((string)$ride['passenger_id'] !== (string)$passenger_id) {
jsonError("This ride does not belong to you");
exit;
}
if ((string)$ride['driver_id'] !== (string)$driver_id) {
jsonError("Driver does not match this ride");
exit;
}
if ($ride['status'] !== 'Finished') {
jsonError("Ride must be finished before rating");
exit;
}
// 🔥 منع تكرار التقييم لنفس الرحلة من نفس الراكب
$stmtDup = $con->prepare("SELECT COUNT(*) FROM `ratingDriver` WHERE ride_id = ? AND passenger_id = ?");
$stmtDup->execute([$ride_id, $passenger_id]);
if ($stmtDup->fetchColumn() > 0) {
jsonError("This ride has already been rated");
exit;
}
$sql = "INSERT INTO `ratingDriver`(
`passenger_id`, `driver_id`, `ride_id`, `rating`, `comment`
) VALUES (
+62 -12
View File
@@ -100,8 +100,8 @@ try {
try {
// Fetch ride data from remote/local DB for server-side calculation
$stmtRideData = $con->prepare("
SELECT id, price AS quoted_price, car_type,
distance AS planned_distance, passenger_id, driver_id
SELECT id, price AS quoted_price, car_type,
distance AS planned_distance, passenger_id, driver_id, price_for_driver
FROM ride WHERE id = ? AND driver_id = ?
LIMIT 1
");
@@ -117,6 +117,20 @@ try {
$kazanPercent = floatval($countryPricing['kazanPercent'] ?? $countryPricing['kazan'] ?? 10); // 🆕 من جدول kazan (kazanPercent هو الاسم الجديد)
$carType = $rideData['car_type'] ?? 'Fixed Price';
// 🔥 [Fix Driver Commission] عند عرض السعر (pricing/get.php) قد تُطبَّق نسبة
// خصم عمولة خاصة بالمنطقة (kazanDiscountFactor من surge:kazan_discounts)
// فتُحفَظ نتيجتها في عمود ride.price_for_driver — وهو "الوعد" الذي رآه
// السائق كـ"أرباحك أعلى" وقت قبول الطلب. إعادة جلب kazanPercent الخام هنا
// كانت تتجاهل ذلك الخصم بالكامل. نشتق نسبة العمولة الفعلية من الفرق
// المحفوظ فعلياً بين السعر المُقتبَس وحصة السائق منه، ونستخدمها بدل
// النسبة الخام، حتى تبقى النسبة المطبقة عند التسوية مطابقة لما وُعد به.
$priceForDriver = floatval($rideData['price_for_driver'] ?? 0);
if ($quotedPrice > 0 && $priceForDriver > 0 && $priceForDriver <= $quotedPrice) {
$effectiveKazanPercent = (($quotedPrice - $priceForDriver) / $quotedPrice) * 100;
error_log("[finish_ride_updates] Using locked commission rate for ride $rideId: {$effectiveKazanPercent}% (was raw {$kazanPercent}%)");
$kazanPercent = $effectiveKazanPercent;
}
// Fixed-price types, Speed & Awfar: use quoted price as-is
$fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car'];
if (in_array($carType, $fixedPriceTypes)) {
@@ -144,6 +158,19 @@ try {
$cleanDist = preg_replace('/[^0-9.]/', '', $actualDistance);
$distanceKm = floatval($cleanDist);
// 🔥 [Fix Price Cap] سقف أعلى على الانحراف عن المسافة المخططة —
// actualDistance يأتي من العميل، بدون هذا السقف يمكن لانجراف GPS
// أو قيمة مُتلاعَب بها أن تُضخّم السعر النهائي بلا حدود. نسمح بهامش
// معقول للانحرافات الحقيقية (تحويلة، إغلاق طريق...) ونقصّ الباقي.
$plannedDistanceKm = floatval($rideData['planned_distance'] ?? 0);
if ($plannedDistanceKm > 0) {
$maxAllowedDistanceKm = max($plannedDistanceKm * 1.5, $plannedDistanceKm + 5);
if ($distanceKm > $maxAllowedDistanceKm) {
error_log("[finish_ride_updates] ⚠️ actualDistance ($distanceKm km) exceeds cap ($maxAllowedDistanceKm km) for ride $rideId — planned was $plannedDistanceKm km. Clamping.");
$distanceKm = $maxAllowedDistanceKm;
}
}
if ($distanceKm <= 0) {
$finalPrice = $quotedPrice; // fallback
} else {
@@ -151,6 +178,13 @@ try {
$perKmRate = getPerKmRate($carType, $countryPricing);
$perMinRate = getPerMinRate($countryPricing);
$durationMin = intval(preg_replace('/[^0-9]/', '', $actualDuration));
// نفس فكرة السقف على المدة: لا نسمح بمدة أكبر من ضعف زمن الرحلة
// المعقول (نفترض حد أقصى واسع 3 ساعات إذا لم تتوفر مدة مخططة)
$maxAllowedDurationMin = 180;
if ($durationMin > $maxAllowedDurationMin) {
error_log("[finish_ride_updates] ⚠️ actualDuration ($durationMin min) exceeds cap ($maxAllowedDurationMin min) for ride $rideId. Clamping.");
$durationMin = $maxAllowedDurationMin;
}
$calculated = ($distanceKm * $perKmRate) + ($durationMin * $perMinRate);
@@ -161,11 +195,14 @@ try {
error_log("[finish_ride_updates] Driver $driver_id has active 0% commission streak!");
}
// السعر النهائي يجب أن يتضمن العمولة دائماً لكي يدفعها الراكب،
// السعر النهائي يجب أن يتضمن العمولة دائماً لكي يدفعها الراكب،
// لكن إذا كانت العمولة صفر للسائق، يتم إعطاؤها للسائق بدلاً من الشركة عبر السيرفر المالي.
$calculated *= (1 + ($kazanPercent / 100));
$finalPrice = max($quotedPrice, round($calculated, 2));
// 🔥 [Fix Price Cap] سقف أعلى مطلق أيضاً على السعر النهائي نفسه
// (دفاع ثانٍ) — لا يتجاوز 1.6 * السعر المُقتبَس أصلاً بأي حال.
$maxAllowedPrice = $quotedPrice > 0 ? $quotedPrice * 1.6 : round($calculated, 2);
$finalPrice = max($quotedPrice, min(round($calculated, 2), $maxAllowedPrice));
}
}
@@ -182,13 +219,12 @@ try {
// 4. Atomic Transaction: Update DBs + Process Payment
// ============================================================
try {
// --- Update Remote DB (con_ride) FIRST ---
if (isset($con_ride)) {
$stmtRemote = $con_ride->prepare(
"UPDATE ride SET status = ?, rideTimeFinish = NOW(), price = ? WHERE id = ? AND status = 'Begin'"
);
$stmtRemote->execute([$newStatus, $finalPrice, $rideId]);
}
// 🔥 [Fix Split-Brain] كان تحديث قاعدة البيانات البعيدة (con_ride) يحدث
// هنا قبل محاولة الدفع وبدون أي Rollback عليه — فإذا فشل الدفع لاحقاً،
// كانت con_ride تبقى 'Finished' بينما المحلية تُرجَع لـ 'Begin'،
// فإعادة محاولة لاحقة تفشل بصمت على con_ride (شرط WHERE status='Begin'
// لم يعد يتحقق) وتُنتج سعرين مختلفين بين القاعدتين. الآن نؤجل تحديث
// con_ride إلى ما بعد نجاح الدفع فعلياً (انظر الأسفل بعد commit()).
// --- BEGIN Local DB Transaction ---
$con->beginTransaction();
@@ -276,7 +312,21 @@ try {
// ✅ Payment succeeded — COMMIT
$con->commit();
// 🔥 [Fix Split-Brain] تحديث القاعدة البعيدة الآن فقط، بعد أن أصبح الدفع
// والتحديث المحلي مؤكدَين نجاحهما — يبقي الحالتين متطابقتين دائماً.
// فشل هذا التحديث best-effort فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً).
if (isset($con_ride)) {
try {
$stmtRemote = $con_ride->prepare(
"UPDATE ride SET status = ?, rideTimeFinish = NOW(), price = ? WHERE id = ? AND status = 'Begin'"
);
$stmtRemote->execute([$newStatus, $finalPrice, $rideId]);
} catch (PDOException $e) {
error_log("[finish_ride_updates] Remote DB (con_ride) update failed for ride $rideId: " . $e->getMessage());
}
}
// 🆕 Update Driver Streak (Increment because ride is finished successfully)
handleDriverStreak($con, $driver_id, 'increment');
@@ -1396,14 +1396,25 @@ class MapDriverController extends GetxController
box.remove(BoxName.passengerID);
box.remove(BoxName.rideId);
// 🔥 [Fix Actual Distance] نُرسل المسافة/المدة الفعليتين المُتراكمتين
// من GPS الحي (currentRideDistanceKm/_rideStartTime) بدل الحقلين
// الثابتين (distance/duration) اللذين يمثلان المسار المخطط أصلاً
// ولا يُحدَّثان أبداً أثناء الرحلة — كانا يُبطلان فعلياً حماية
// "إعادة حساب السعر من المسافة الفعلية" في الباك إند.
final double actualDistanceKm =
currentRideDistanceKm > 0 ? currentRideDistanceKm : safeParseDouble(distance);
final int actualDurationMinutes = _rideStartTime != null
? (DateTime.now().difference(_rideStartTime!).inSeconds / 60).ceil()
: safeParseInt(duration);
// تجهيز البيانات الخام الموحدة للسيرفر ليقوم بمعالجة الدفع والإنهاء معاً بنظام المعاملة الواحدة
final finishPayload = {
'rideId': rideId.toString(),
'driver_id': box.read(BoxName.driverID).toString(),
'passengerId': passengerId.toString(),
'status': 'Finished',
'actualDistance': distance.toString(),
'actualDuration': duration.toString(),
'actualDistance': actualDistanceKm.toString(),
'actualDuration': actualDurationMinutes.toString(),
'walletChecked': walletChecked.toString(),
'passengerWalletBurc': passengerWalletBurc.toString(),
'passengerToken': tokenPassenger.toString(),
@@ -1,5 +1,6 @@
import 'package:siro_driver/controller/firebase/firbase_messge.dart';
import 'package:siro_driver/controller/home/captin/map_driver_controller.dart';
import 'package:siro_driver/print.dart';
import 'package:siro_driver/views/widgets/error_snakbar.dart';
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
@@ -112,13 +113,18 @@ class RateController extends GetxController {
middleText: '',
confirm: MyElevatedButton(title: 'Ok', onPressed: () => Get.back()));
} else {
await CRUD().post(link: "${AppLink.server}/ride/rate/add.php", payload: {
final rateResponse =
await CRUD().post(link: "${AppLink.server}/ride/rate/add.php", payload: {
'passenger_id': passengerId,
'driverID': box.read(BoxName.driverID).toString(),
'rideId': rideId.toString(),
'rating': selectedRateItemId.toString(),
'comment': comment.text ?? 'none',
});
// 🔥 لا نمنع إكمال الرحلة إذا فشل التقييم، لكن يجب تسجيله بدل تجاهله بصمت
if (rateResponse is Map && rateResponse['status'] != 'success') {
Log.print('⚠️ Failed to submit passenger rating: $rateResponse');
}
CRUD().sendEmail(AppLink.sendEmailToPassengerForTripDetails, {
'startLocation':
+3 -4
View File
@@ -1056,10 +1056,9 @@ packages:
intaleq_maps:
dependency: "direct main"
description:
name: intaleq_maps
sha256: b74c4e6f1d890f81bf253c4d3996db53149b64fcbf9869b279d417067f73128b
url: "https://pub.dev"
source: hosted
path: "../../map-saas/packages/flutter-sdk"
relative: true
source: path
version: "2.2.0"
internet_connection_checker:
dependency: "direct main"
+5 -1
View File
@@ -78,7 +78,11 @@ dependencies:
internet_connection_checker: ^3.0.1
connectivity_plus: ^6.1.5
app_links: ^7.0.0
intaleq_maps: ^2.2.0
# 🔧 مؤقتاً: نستخدم نفس نسخة الحزمة المحلية المُصلَحة التي يستخدمها تطبيق
# السائق (بدل النسخة 2.2.0 المنشورة على pub.dev) لاختبار إصلاح مقارنة
# Polyline/Marker على التطبيقين معاً قبل رفع نسخة رسمية جديدة على pub.dev.
intaleq_maps:
path: ../../map-saas/packages/flutter-sdk/
socket_io_client: 1.0.2
# home_widget: ^0.7.0+1