feat: initial commit
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user