Files
Hamza-AyedandClaude Opus 5 4d8414c96b feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة
ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ.

الخريطة:
  backend · payment_server · loction_server · ride_server ·
  passenger_server · docker · dashboard · stress_test  → الجذر
  siro_rider  → apps/rider          siro_driver  → apps/driver
  siro_admin  → dashboards/admin    siro_service → dashboards/service
  android_bot → apps/android_bot    socialBot    → apps/socialBot

نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب)
لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً:
كل ما يلي يصير فرقاً مقروءاً مقابل المصدر.

لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز،
سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh
(ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في
مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و
dashboards/transit-web).

⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة:
1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر):
   كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner.
2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist)
   يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً.
3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع →
   يجب ضمّ الحزم داخله أسوة بـ apps/rider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:14:13 +03:00

310 lines
12 KiB
PHP

<?php
// ============================================================
// transit/functions.php — دوال خاصة بمنصة مواصلاتي فقط
//
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
// • filterRequest() ← core/helpers.php
// • jsonSuccess() / jsonError() ← core/helpers.php
// • appLog() / securityLog() ← core/helpers.php
// • normalizePhone($phone) ← core/helpers.php — يطبّع الهاتف دائماً قبل التشفير أو الاستعلام
// • $encryptionHelper ← مهيَّأ في bootstrap.php
// • $redis ← مهيَّأ في bootstrap.php
// ============================================================
// Lazy-loaded services (bootstrap لا يحمّلها لأنها ليست مطلوبة لكل endpoint)
require_once __DIR__ . '/../core/Services/OtpService.php';
require_once __DIR__ . '/../core/Services/FcmService.php';
// sendWhatsAppFromServer — الموجودة في backend/functions.php القديمة
if (file_exists(__DIR__ . '/../functions.php')) {
require_once __DIR__ . '/../functions.php';
}
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
function requireTransitFields(array $fields): void
{
foreach ($fields as $f) {
if (filterRequest($f) === null) {
jsonError("Missing required field: $f", 400);
}
}
}
// ── Session مشرف الويب (OTP-based — مستقلة عن JWT) ──────────
function transitCreateSession(int $adminId, int $orgId): string
{
global $redis;
$token = bin2hex(random_bytes(32)); // 64 hex chars
$hash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$con = Database::get('transit');
$con->prepare(
"INSERT INTO transit_sessions (admin_id, org_id, token_hash, ip, user_agent, expires_at)
VALUES (?,?,?,?,?,?)"
)->execute([$adminId, $orgId, $hash, $ip, $ua, $expiresAt]);
if ($redis) {
$redis->setEx(
"transit:session:{$hash}",
86400,
json_encode(['admin_id' => $adminId, 'org_id' => $orgId])
);
}
return $token;
}
function transitAuthAdmin(): array
{
global $redis;
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_TRANSIT_TOKEN'] ?? '';
$token = str_replace('Bearer ', '', $header);
if (!$token) jsonError('Missing admin session token', 401);
$hash = hash('sha256', $token);
if ($redis) {
$val = $redis->get("transit:session:{$hash}");
if ($val) {
$data = json_decode($val, true);
if ($data) return $data;
}
jsonError('Session expired or invalid', 401);
}
// Fallback MySQL
$con = Database::get('transit');
$st = $con->prepare(
"SELECT admin_id, org_id FROM transit_sessions
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
);
$st->execute([$hash]);
$row = $st->fetch();
if (!$row) jsonError('Session expired or invalid', 401);
return $row;
}
// ── OTP مشرف الويب (يستخدم OtpService الموجود) ─────────────
function transitSendAdminOtp(string $phone): void
{
global $redis;
$otpSvc = new OtpService($redis);
$otp = $otpSvc->generate($phone, 3); // 3 أرقام فقط (100–999) حسب سياسة مواصلاتي
$message = "رمز تسجيل الدخول لمنصة مواصلاتي: {$otp}\nصالح لمدة 5 دقائق.";
sendWhatsAppFromServer($phone, $message);
}
function transitVerifyAdminOtp(string $phone, string $otp): bool
{
global $redis;
$otpSvc = new OtpService($redis);
return $otpSvc->verify($phone, $otp);
}
function transitIsOtpLocked(string $phone): bool
{
global $redis;
$otpSvc = new OtpService($redis);
return $otpSvc->isLocked($phone);
}
// ── تشفير الرقم الجامعي / الوظيفي (يستخدم EncryptionHelper) ─
function transitEncryptStudentId(string $raw): string
{
global $encryptionHelper;
return $encryptionHelper->encryptData($raw);
}
function transitDecryptStudentId(string $enc): string
{
global $encryptionHelper;
return (string)$encryptionHelper->decryptData($enc);
}
// ── إشعارات FCM Topic للخطوط ────────────────────────────────
function transitRouteTopic(int $routeId): string
{
return 'transit_route_' . $routeId;
}
function transitOrgTopic(int $orgId): string
{
return 'transit_org_' . $orgId;
}
// إشعار مباشر لراكب واحد عبر موضوعه الشخصي passenger_{id}
// (التطبيق يشترك بهذا الموضوع عند تسجيل الدخول — انظر firbase_messge.dart)
function transitSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
{
if (!$passengerId) return;
transitSendTopicNotification('passenger_' . $passengerId, $title, $body, $data);
}
// إشعار أولياء الأمور المرتبطين بعضوية طالب — يحترم أعمدة notify_* بالجدول
// $event: depart | board | arrive | return
function transitNotifyGuardians(int $enrollmentId, string $event, string $title, string $body): void
{
global $transit_con;
if (!$transit_con) return;
$column = 'notify_' . $event;
if (!in_array($event, ['depart', 'board', 'arrive', 'return'])) return;
$st = $transit_con->prepare(
"SELECT guardian_passenger_id FROM transit_guardians
WHERE enrollment_id=? AND {$column}=1"
);
$st->execute([$enrollmentId]);
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $guardianId) {
transitSendNotificationToPassenger((string)$guardianId, $title, $body, ['type' => 'transit_guardian_' . $event]);
}
}
function transitSendTopicNotification(string $topic, string $title, string $body, array $data = []): void
{
global $redis;
$fcm = new FcmService($redis);
$result = $fcm->sendToTopic($topic, $title, $body, $data);
if ($result['status'] !== 'success') {
appLog("[TRANSIT][FCM] Topic push failed on '{$topic}': " . json_encode($result), 'WARNING');
}
}
// ── موقع الباص في Redis سيرفر الموقع ($redisLocation) ────────
// موقع الباص يكتبه driver_socket على Redis سيرفر الموقع (بدون بادئة siro:)
// تماماً مثل موقع السائق العادي — لذلك نقرؤه/نكتبه عبر $redisLocation
// وليس $redis الرئيسي (الذي يضيف بادئة siro: ولا يراه السوكت).
function transitUpdateBusPosition(int $tripId, float $lat, float $lng): void
{
global $redisLocation;
if (!$redisLocation) return;
$redisLocation->hMSet("transit:trip:{$tripId}:pos", ['lat' => $lat, 'lng' => $lng, 'ts' => time()]);
$redisLocation->expire("transit:trip:{$tripId}:pos", 86400);
}
function transitGetBusPosition(int $tripId): ?array
{
global $redisLocation;
if (!$redisLocation) return null;
$pos = $redisLocation->hGetAll("transit:trip:{$tripId}:pos");
return ($pos && isset($pos['lat'])) ? $pos : null;
}
// ── ملكية الرحلة (Trip Ownership) ─────────────────────────────
// تُكتب عند بدء الرحلة على Redis سيرفر الموقع، ليتحقق منها driver_socket
// قبل قبول أي update_bus_location — يمنع أي سائق آخر من انتحال trip_id
// أو البث لخط لا يملكه. main_driver_id هو نفسه sub في JWT (الحساب في
// جدول driver الرئيسي)، وليس transit_drivers.id.
function transitSetTripOwner(int $tripId, string $mainDriverId, int $routeId): void
{
global $redisLocation;
if (!$redisLocation || !$mainDriverId) return;
$key = "transit:trip:{$tripId}:owner";
$redisLocation->hMSet($key, ['driver_id' => $mainDriverId, 'route_id' => $routeId]);
$redisLocation->expire($key, 86400);
}
function transitClearTripOwner(int $tripId): void
{
global $redisLocation;
if (!$redisLocation) return;
$redisLocation->del("transit:trip:{$tripId}:owner");
}
// ── عضوية الراكب في Redis (location Redis — بدون بادئة siro:) ──────
// يستخدمها passenger_socket.php للتحقق قبل قبول subscribe_transit_route.
// تُكتب عند: تفعيل عضوية برقم الكشف، أو موافقة المشرف على الطلب اليدوي.
// تُحذف عند: رفض أو تعليق العضوية.
function transitCacheEnrollment(int $orgId, string $passengerId): void
{
global $redisLocation;
if (!$redisLocation || !$passengerId) return;
$key = "transit:org_members:{$orgId}";
$redisLocation->sAdd($key, $passengerId);
$redisLocation->expire($key, 7 * 86400); // تجديد TTL سبعة أيام
}
function transitDropEnrollment(int $orgId, string $passengerId): void
{
global $redisLocation;
if (!$redisLocation || !$passengerId) return;
$redisLocation->sRem("transit:org_members:{$orgId}", $passengerId);
}
// ── كاش السائق في Redis ($redis الرئيسي — بادئة siro:) ──────────
// جدول transit_drivers صغير، لكن endpoints الرحلات تستعلم عنه بكثرة
// (today, start, end, delay, board_passenger, stop_passengers) — خصوصاً
// board_passenger أثناء مسح تذاكر الصعود. نمط read-through: نقرأ من Redis
// أولاً، وإن لم يوجد نستعلم القاعدة ونخزّن. المفتاح بـ main_driver_id
// (المعرّف الآتي من JWT). نبطل الكاش عند أي تغيير حالة (تفعيل/تعليق).
//
// نخزّن فقط السائقين النشطين (status='active'). أي سجل غير نشط لا يُكاش
// حتى لا نحتاج إبطالاً عند التفعيل — أول استعلام بعد التفعيل يبني الكاش.
function transitResolveDriver(string $mainDriverId): ?array
{
global $redis, $transit_con;
if (!$mainDriverId) return null;
$key = "transit:driver:{$mainDriverId}";
if ($redis) {
$cached = $redis->hGetAll($key);
if (!empty($cached) && isset($cached['id'])) {
return ['id' => (int)$cached['id'], 'org_id' => (int)$cached['org_id']];
}
}
if (!$transit_con) $transit_con = Database::get('transit');
$st = $transit_con->prepare(
"SELECT id, org_id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
);
$st->execute([$mainDriverId]);
$driver = $st->fetch();
if (!$driver) return null;
$resolved = ['id' => (int)$driver['id'], 'org_id' => (int)$driver['org_id']];
if ($redis) {
$redis->hMSet($key, $resolved);
$redis->expire($key, 3600); // ساعة — طبقة حماية إن فات إبطالٌ ما
}
return $resolved;
}
function transitInvalidateDriverCache(string $mainDriverId): void
{
global $redis;
if ($redis && $mainDriverId) $redis->del("transit:driver:{$mainDriverId}");
}
// ── days_mask helpers ────────────────────────────────────────
// bit0=Sun … bit6=Sat | 62 = 0b0111110 = الأحد–الخميس
function transitDayActive(int $mask): bool
{
return (bool)(($mask >> (int)date('w')) & 1);
}