lPush(self::REDIS_KEY, $payload); } catch (Throwable $e) { self::logError('Redis push failed: ' . $e->getMessage()); } } return self::pushFile($payload); } /** * @return array */ public static function popBatch(int $limit = 100) { $limit = max(1, min(500, $limit)); $jobs = []; $redis = self::redisClient(); if ($redis) { try { for ($i = 0; $i < $limit; $i++) { $raw = $redis->rPop(self::REDIS_KEY); if ($raw === false || $raw === null) { break; } $decoded = json_decode((string) $raw, true); if (is_array($decoded)) { $jobs[] = $decoded; } } if ($jobs !== []) { return $jobs; } } catch (Throwable $e) { self::logError('Redis pop failed: ' . $e->getMessage()); } } return self::popFileBatch($limit); } /** * 请求 shutdown 时轻量 drain(最多 $limit 条) */ public static function drain(int $limit = 5) { if (!class_exists('VisitorLogWorker', false)) { require_once __DIR__ . '/VisitorLogWorker.php'; } VisitorLogWorker::processBatch($limit); } /** * @return Redis|null */ private static function redisClient() { if (self::$redisAvailable === false) { return null; } if (!extension_loaded('redis') || !defined('REDIS_ENABLED') || strtoupper(REDIS_ENABLED) !== 'ON') { self::$redisAvailable = false; return null; } try { $r = new Redis(); $timeout = defined('REDIS_TIMEOUT') ? (float) REDIS_TIMEOUT : 1.0; if (!@$r->connect(REDIS_HOST, (int) REDIS_PORT, $timeout)) { self::$redisAvailable = false; return null; } if (defined('REDIS_PASSWORD') && REDIS_PASSWORD !== '') { $r->auth(REDIS_PASSWORD); } self::$redisAvailable = true; return $r; } catch (Throwable $e) { self::$redisAvailable = false; return null; } } private static function queueDir() { $dir = dirname(__DIR__, 2) . '/storage/' . self::FILE_DIR; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } return $dir; } private static function pushFile($payload) { $dir = self::queueDir(); $file = $dir . '/pending.ndjson'; $fp = @fopen($file, 'ab'); if (!$fp) { return false; } if (flock($fp, LOCK_EX)) { fwrite($fp, $payload . "\n"); fflush($fp); flock($fp, LOCK_UN); } fclose($fp); return true; } /** * @return array */ private static function popFileBatch(int $limit) { $dir = self::queueDir(); $file = $dir . '/pending.ndjson'; if (!is_file($file)) { return []; } $fp = @fopen($file, 'c+'); if (!$fp) { return []; } $jobs = []; if (!flock($fp, LOCK_EX)) { fclose($fp); return []; } $lines = []; while (($line = fgets($fp)) !== false) { $line = trim($line); if ($line !== '') { $lines[] = $line; } } if ($lines === []) { flock($fp, LOCK_UN); fclose($fp); return []; } $take = array_slice($lines, 0, $limit); $remain = array_slice($lines, $limit); ftruncate($fp, 0); rewind($fp); foreach ($remain as $line) { fwrite($fp, $line . "\n"); } fflush($fp); flock($fp, LOCK_UN); fclose($fp); foreach ($take as $line) { $decoded = json_decode($line, true); if (is_array($decoded)) { $jobs[] = $decoded; } } return $jobs; } /** * @param array $job * @param string $error */ public static function moveToDead(array $job, $error) { $dir = self::queueDir() . '/dead'; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $job['dead_at'] = date('Y-m-d H:i:s'); $job['dead_error'] = (string) $error; $name = $dir . '/' . date('Ymd_His') . '_' . substr(md5(json_encode($job)), 0, 8) . '.json'; @file_put_contents($name, json_encode($job, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); self::logError('Dead letter: ' . $error); } public static function logError($message) { $handle = @fopen(dirname(__DIR__, 2) . '/err.txt', 'a+'); if ($handle) { fwrite($handle, date('Y-m-d H:i:s') . ' | VisitorLogQueue | ' . $message . "\n"); fclose($handle); } } }