Files
tripz-llc/backend/core/Services/FcmService.php
T
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

249 lines
9.3 KiB
PHP

<?php
// ============================================================
// core/Services/FcmService.php
// إرسال FCM مع كاش توكن في Redis (بدل ملف)
// ============================================================
class FcmService
{
private ?Redis $redis;
private string $serviceAccountFile;
public function __construct(?Redis $redis = null)
{
$this->redis = $redis;
$envPath = getenv('FIREBASE_SERVICE_ACCOUNT_PATH') ?: '';
if (!empty($envPath) && file_exists($envPath)) {
$this->serviceAccountFile = $envPath;
} elseif (file_exists('/keys/firebase_service_account.json')) {
$this->serviceAccountFile = '/keys/firebase_service_account.json';
} elseif (file_exists('/keys/service-account.json')) {
$this->serviceAccountFile = '/keys/service-account.json';
} elseif (file_exists(__DIR__ . '/../../keys/firebase_service_account.json')) {
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json';
} elseif (file_exists(__DIR__ . '/../../ride/firebase/service-account.json')) {
$this->serviceAccountFile = __DIR__ . '/../../ride/firebase/service-account.json';
} else {
$this->serviceAccountFile = __DIR__ . '/../../keys/firebase_service_account.json'; // Default fallback
}
}
// ── إرسال إشعار ────────────────────────────────────────
public function send(
string $token,
string $title,
string $body,
array $data = [],
string $category = 'Order',
string $tone = 'ding'
): array {
$accessToken = $this->getAccessToken();
if (!$accessToken) {
return ['status' => 'error', 'message' => 'No access token'];
}
if (!file_exists($this->serviceAccountFile)) {
return ['status' => 'error', 'message' => 'Service account file missing'];
}
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
$projectId = $creds['project_id'];
$fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
$finalData = array_merge($data, [
'title' => $title,
'body' => $body,
'tone' => $tone,
'category' => $category,
'type' => $category,
]);
// FCM يشترط أن تكون كل القيم strings
$processedData = array_map(
fn($v) => is_array($v) || is_object($v)
? json_encode($v, JSON_UNESCAPED_UNICODE)
: (string)$v,
$finalData
);
$payload = [
'message' => [
'token' => $token,
'data' => $processedData,
'android' => [
'priority' => 'HIGH',
'notification' => [
'sound' => $tone === 'ding' ? 'default' : $tone,
'channel_id' => 'high_importance_channel'
]
],
],
];
if (!empty($title) && !empty($body)) {
$payload['message']['notification'] = [
'title' => $title,
'body' => $body,
];
$iosSound = $tone === 'ding' || $tone === 'default' ? 'default' : (str_ends_with($tone, '.caf') ? $tone : $tone . '.caf');
$payload['message']['apns'] = [
'payload' => [
'aps' => [
'sound' => $iosSound
]
]
];
} else {
$payload['message']['apns'] = [
'headers' => [
'apns-priority' => '5',
'apns-push-type' => 'background'
],
'payload' => [
'aps' => [
'content-available' => 1
]
]
];
}
$ch = curl_init($fcmUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $accessToken",
'Content-Type: application/json; charset=UTF-8',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FRESH_CONNECT => false, // إعادة استخدام الاتصال
CURLOPT_FORBID_REUSE => false,
CURLOPT_TCP_KEEPALIVE => 1,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_errno($ch);
curl_close($ch);
error_log("[FCM_DEBUG] Token: " . substr($token, 0, 10) . "... Payload: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . " | Result: $httpCode - $result");
if ($curlErr) {
return ['status' => 'error', 'message' => 'CURL error'];
}
return $httpCode === 200
? ['status' => 'success']
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
}
// ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ──
public function sendToTopic(
string $topic,
string $title,
string $body,
array $data = []
): array {
$accessToken = $this->getAccessToken();
if (!$accessToken) return ['status' => 'error', 'message' => 'No access token'];
if (!file_exists($this->serviceAccountFile)) {
return ['status' => 'error', 'message' => 'Service account file missing'];
}
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
$projectId = $creds['project_id'];
$fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send";
$processedData = array_map(
fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v,
array_merge($data, ['title' => $title, 'body' => $body])
);
$payload = [
'message' => [
'topic' => $topic,
'notification' => ['title' => $title, 'body' => $body],
'data' => $processedData,
'android' => ['priority' => 'HIGH'],
],
];
$ch = curl_init($fcmUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $httpCode === 200
? ['status' => 'success']
: ['status' => 'error', 'code' => $httpCode, 'response' => $result];
}
// ── Access Token مع Redis Cache ─────────────────────────
private function getAccessToken(): ?string
{
// 1. من Redis
if ($this->redis) {
$cached = $this->redis->get('google_fcm_access_token');
if ($cached) return $cached;
}
// 2. طلب جديد
$token = $this->fetchGoogleToken();
if ($token && $this->redis) {
$this->redis->setex('google_fcm_access_token', 3500, $token);
}
return $token;
}
private function fetchGoogleToken(): ?string
{
if (!file_exists($this->serviceAccountFile)) return null;
$creds = json_decode(file_get_contents($this->serviceAccountFile), true);
$clientEmail = $creds['client_email'];
$privateKey = $creds['private_key'];
$now = time();
$header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '=');
$claim = rtrim(strtr(base64_encode(json_encode([
'iss' => $clientEmail,
'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
])), '+/', '-_'), '=');
$signature = '';
openssl_sign("$header.$claim", $signature, $privateKey, 'SHA256');
$jwt = "$header.$claim." . rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$res = curl_exec($ch);
curl_close($ch);
return json_decode($res, true)['access_token'] ?? null;
}
}