diff --git a/apps/teacher_app/pubspec.lock b/apps/teacher_app/pubspec.lock index 24ba122..e1ae340 100644 --- a/apps/teacher_app/pubspec.lock +++ b/apps/teacher_app/pubspec.lock @@ -340,10 +340,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -356,10 +356,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" nested: dependency: transitive description: @@ -585,10 +585,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" typed_data: dependency: transitive description: diff --git a/backend/app/Services/AiVideoAnalyzerService.php b/backend/app/Services/AiVideoAnalyzerService.php index 40ee466..11521ea 100644 --- a/backend/app/Services/AiVideoAnalyzerService.php +++ b/backend/app/Services/AiVideoAnalyzerService.php @@ -18,9 +18,36 @@ namespace App\Services; use App\Core\Database; use App\Core\Security; +use App\Core\RedisClient; class AiVideoAnalyzerService { + /** + * Select a Gemini key from the configured comma-separated pool. Redis + * provides a process-safe round robin counter across PHP-FPM workers. + * No key material is logged or returned to callers. + */ + private static function nextGeminiApiKey(): string + { + $raw = trim((string)(getenv('GEMINI_API_KEYS') ?: getenv('GEMINI_API_KEY') ?: '')); + $keys = array_values(array_filter(array_map('trim', explode(',', $raw)))); + if (empty($keys)) { + return ''; + } + if (count($keys) === 1) { + return $keys[0]; + } + + try { + $turn = (int)RedisClient::getInstance()->incr('saqel:gemini:round_robin'); + return $keys[($turn - 1) % count($keys)]; + } catch (\Throwable $e) { + // The analysis can still proceed during a Redis outage. The time + // bucket alternates keys without exposing either value. + return $keys[(int)(floor(time() / 60) % count($keys))]; + } + } + /** * Fail-closed admission gate for teacher uploads. The media stays in PHP's * temporary upload area until Gemini and technical checks approve it. @@ -50,7 +77,7 @@ class AiVideoAnalyzerService if (!$hasVideo || !$hasAudio || $duration <= 0 || $duration > 1500) { return ['decision' => 'rejected', 'reason' => 'الفيديو يجب أن يحتوي صورة وصوتاً وأن لا يتجاوز 25 دقيقة', 'duration_seconds' => $duration]; } - $key = trim((string)getenv('GEMINI_API_KEY')); + $key = self::nextGeminiApiKey(); if ($key === '') return ['decision' => 'needs_manual_review', 'reason' => 'Gemini غير مهيأ؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration]; // Extract a representative frame; Gemini receives real media evidence, @@ -200,7 +227,7 @@ class AiVideoAnalyzerService public static function evaluatePedagogicalQuality(int $lessonId, string $lessonTitle, int $duration, array $curriculum): array { try { - $geminiKey = getenv('GEMINI_API_KEY'); + $geminiKey = self::nextGeminiApiKey(); $alignmentScore = 0.0; $clarityScore = 0.0; $outcomes = [ @@ -312,7 +339,7 @@ class AiVideoAnalyzerService */ private static function generateAnalysis(string $lessonTitle, int $duration, array $curriculum): array { - $geminiKey = getenv('GEMINI_API_KEY'); + $geminiKey = self::nextGeminiApiKey(); if (!empty($geminiKey)) { try { diff --git a/backend/app/bootstrap.php b/backend/app/bootstrap.php index 059323e..87f9587 100644 --- a/backend/app/bootstrap.php +++ b/backend/app/bootstrap.php @@ -27,22 +27,28 @@ spl_autoload_register(function ($class) { // 2. Load Environment Variables with Multi-Path Fallback try { - $candidatePaths = [ - '/home/intaleqapp-saqel/.env', - '/home/intaleqapp-saqel/htdocs/saqel.intaleqapp.com/.env', - '/home/intaleqapp-saqel/htdocs/saqel.intaleqapp.com/saqel/.env', - '/home/intaleqapp-saqel/htdocs/saqel.intaleqapp.com/saqel/backend/.env', - APP_ROOT . '/.env', - APP_ROOT . '/../.env' - ]; - + // Production secrets belong outside the web/document root. Do not load + // backend/.env: it may be a placeholder and must never shadow the real + // CloudPanel environment file. + $envPath = getenv('SAQEL_ENV_FILE') ?: '/home/intaleqapp-saqel/.env'; $loaded = false; - foreach ($candidatePaths as $path) { - if (file_exists($path)) { - define('LOADED_ENV_PATH', $path); - \App\Core\Env::load($path); - $loaded = true; - break; + if (file_exists($envPath)) { + \App\Core\Env::load($envPath); + define('LOADED_ENV_PATH', $envPath); + $loaded = true; + + // Support either GEMINI_API_KEYS=key1,key2 or the legacy singular + // variable containing a comma-separated pool. Legacy services receive + // one safe key; services with round-robin support consume the pool. + $geminiPool = trim((string)(getenv('GEMINI_API_KEYS') ?: getenv('GEMINI_API_KEY') ?: '')); + $geminiKeys = array_values(array_filter(array_map('trim', explode(',', $geminiPool)))); + if (!empty($geminiKeys)) { + putenv('GEMINI_API_KEYS=' . implode(',', $geminiKeys)); + $_ENV['GEMINI_API_KEYS'] = implode(',', $geminiKeys); + $_SERVER['GEMINI_API_KEYS'] = implode(',', $geminiKeys); + putenv('GEMINI_API_KEY=' . $geminiKeys[0]); + $_ENV['GEMINI_API_KEY'] = $geminiKeys[0]; + $_SERVER['GEMINI_API_KEY'] = $geminiKeys[0]; } }