219 lines
5.8 KiB
PHP
219 lines
5.8 KiB
PHP
<?php
|
|
/**
|
|
* 访客日志异步写入队列(Redis 优先,文件降级)
|
|
*/
|
|
class VisitorLogQueue
|
|
{
|
|
const REDIS_KEY = 'cloak:visitor_log_queue';
|
|
const FILE_DIR = 'visitor_log_queue';
|
|
|
|
/** @var bool|null */
|
|
private static $redisAvailable = null;
|
|
|
|
/**
|
|
* @param array $job 需含 type: insert_full|enrich
|
|
* @return bool
|
|
*/
|
|
public static function push(array $job)
|
|
{
|
|
$job['queued_at'] = date('Y-m-d H:i:s');
|
|
$payload = json_encode($job, JSON_UNESCAPED_UNICODE);
|
|
if ($payload === false) {
|
|
return false;
|
|
}
|
|
|
|
$redis = self::redisClient();
|
|
if ($redis) {
|
|
try {
|
|
return (bool) $redis->lPush(self::REDIS_KEY, $payload);
|
|
} catch (Throwable $e) {
|
|
self::logError('Redis push failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
return self::pushFile($payload);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, 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<int, 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);
|
|
}
|
|
}
|
|
}
|