mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
240 lines
7.3 KiB
PHP
240 lines
7.3 KiB
PHP
<?php
|
||
|
||
namespace base\common\service;
|
||
|
||
use DateTime;
|
||
|
||
/**
|
||
* Nginx access log 解析器(Base 层).
|
||
*
|
||
* 匹配 analytics log_format(11 字段):
|
||
* remote_addr - remote_user [time_local] "request" status body_bytes_sent "referer" "ua" request_time upstream_response_time bytes_sent
|
||
*
|
||
* 示例行:
|
||
* 192.168.32.1 - - [27/Jul/2026:16:20:15 +0000] "GET / HTTP/1.1" 499 0 "-" "Mozilla/5.0 (...)" 5.085 5.086 0
|
||
*
|
||
* 业务侧通过 app/common/service/NginxLogParser 覆盖本类方法.
|
||
*/
|
||
class NginxLogParserBase
|
||
{
|
||
/** 爬虫 UA 关键词(大小写不敏感匹配) */
|
||
public const SPIDER_KEYWORDS = [
|
||
'Googlebot', 'Baiduspider', 'bingbot', 'Sogou', '360Spider',
|
||
'YisouSpider', 'PetalBot', 'AhrefsBot', 'semrushbot', 'DotBot',
|
||
];
|
||
|
||
/** 静态资源扩展名 */
|
||
public const STATIC_EXTS = [
|
||
'css', 'js', 'png', 'jpg', 'jpeg', 'gif', 'ico',
|
||
'woff', 'woff2', 'ttf', 'svg', 'map',
|
||
];
|
||
|
||
/** URI 最大存储长度 */
|
||
protected const URI_MAX_LENGTH = 255;
|
||
|
||
/** UA 最大存储长度 */
|
||
protected const UA_MAX_LENGTH = 500;
|
||
|
||
/** analytics log_format 正则(11 捕获组) */
|
||
protected const LINE_PATTERN = '/^(\S+) - (\S+) \[(.+?)\] "([^"]*)" (\d+) (\d+) "([^"]*)" "([^"]*)" (\S+) (\S+) (\d+)$/';
|
||
|
||
/** $request 拆分正则:METHOD URI HTTP/version */
|
||
protected const REQUEST_PATTERN = '/^(\S+)\s+(\S+)\s+(HTTP\/\S+)$/';
|
||
|
||
/**
|
||
* 解析单行 nginx access log.
|
||
*
|
||
* @param string $line 原始日志行
|
||
*
|
||
* @return array|null 解析成功返回字段数组,正则不匹配或 request 畸形返回 null
|
||
*/
|
||
public function parse(string $line): ?array
|
||
{
|
||
$line = trim($line);
|
||
if ($line === '') {
|
||
return null;
|
||
}
|
||
|
||
if (!preg_match(self::LINE_PATTERN, $line, $m)) {
|
||
return null;
|
||
}
|
||
|
||
// $m[1..11] = 11 个捕获组
|
||
$remoteAddr = $m[1];
|
||
$remoteUser = $m[2];
|
||
$timeLocalStr = $m[3];
|
||
$request = $m[4];
|
||
$status = (int) $m[5];
|
||
$bodyBytes = (int) $m[6];
|
||
$referer = $m[7];
|
||
$userAgent = $m[8];
|
||
$requestTime = $m[9];
|
||
$upstreamTime = $m[10];
|
||
$bytesSent = (int) $m[11];
|
||
|
||
// time_local 字符串 → Unix 时间戳(DateTime 自动转业务时区)
|
||
$timeLocal = $this->parseTimeLocal($timeLocalStr);
|
||
if ($timeLocal === null) {
|
||
return null;
|
||
}
|
||
|
||
// 拆分 request → method / uri / http_version
|
||
[$method, $uri, $httpVersion] = $this->splitRequest($request);
|
||
if ($method === null) {
|
||
// 畸形 request(含空格等),无法提取核心字段
|
||
return null;
|
||
}
|
||
|
||
// URI 截断 + 拆 query_string
|
||
$uri = substr($uri, 0, self::URI_MAX_LENGTH);
|
||
$queryString = '';
|
||
$qsPos = strpos($uri, '?');
|
||
if ($qsPos !== false) {
|
||
$queryString = substr($uri, $qsPos + 1);
|
||
$uri = substr($uri, 0, $qsPos);
|
||
}
|
||
|
||
// UA 截断
|
||
$userAgent = substr($userAgent, 0, self::UA_MAX_LENGTH);
|
||
|
||
// referer 域名提取
|
||
$refererDomain = $this->extractRefererDomain($referer);
|
||
|
||
// UA 分类
|
||
[$uaType, $uaName] = $this->classifyUa($userAgent);
|
||
|
||
return [
|
||
'remote_addr' => $remoteAddr,
|
||
'remote_user' => $remoteUser,
|
||
'time_local' => $timeLocal,
|
||
'method' => $method,
|
||
'uri' => $uri,
|
||
'query_string' => $queryString,
|
||
'http_version' => $httpVersion,
|
||
'status' => $status,
|
||
'body_bytes_sent' => $bodyBytes,
|
||
'http_referer' => $referer,
|
||
'referer_domain' => $refererDomain,
|
||
'http_user_agent' => $userAgent,
|
||
'ua_type' => $uaType,
|
||
'ua_name' => $uaName,
|
||
'request_time' => $requestTime !== '-' ? (float) $requestTime : 0.0,
|
||
'upstream_response_time' => $upstreamTime !== '-' ? (float) $upstreamTime : null,
|
||
'bytes_sent' => $bytesSent,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 首行格式自检:判断给定行是否匹配 analytics log_format.
|
||
*/
|
||
public function isFormatMatch(string $sampleLine): bool
|
||
{
|
||
return preg_match(self::LINE_PATTERN, trim($sampleLine)) === 1;
|
||
}
|
||
|
||
/**
|
||
* UA 分类.
|
||
*
|
||
* @param string $ua User-Agent 字符串
|
||
*
|
||
* @return array{0:string,1:string} [ua_type, ua_name];spider 时 ua_name = 匹配的关键词
|
||
*/
|
||
public function classifyUa(string $ua): array
|
||
{
|
||
if ($ua === '' || $ua === '-') {
|
||
return ['unknown', ''];
|
||
}
|
||
|
||
foreach (self::SPIDER_KEYWORDS as $keyword) {
|
||
if (stripos($ua, $keyword) !== false) {
|
||
return ['spider', $keyword];
|
||
}
|
||
}
|
||
|
||
return ['browser', ''];
|
||
}
|
||
|
||
/**
|
||
* 提取 referer 域名.
|
||
*
|
||
* @param string $referer 原始 referer 值
|
||
*
|
||
* @return string 域名;空或 "-" 返回 'direct';解析失败返回 'unknown'
|
||
*/
|
||
public function extractRefererDomain(string $referer): string
|
||
{
|
||
if ($referer === '' || $referer === '-') {
|
||
return 'direct';
|
||
}
|
||
|
||
$host = parse_url($referer, PHP_URL_HOST);
|
||
if ($host === false || $host === null || $host === '') {
|
||
return 'unknown';
|
||
}
|
||
|
||
return $host;
|
||
}
|
||
|
||
/**
|
||
* 拆分 request → [method, uri, http_version].
|
||
*
|
||
* @param string $request 原始 request 字段("GET /path HTTP/1.1")
|
||
*
|
||
* @return array{0:?string,1:?string,2:?string} 匹配失败三项均为 null
|
||
*/
|
||
public function splitRequest(string $request): array
|
||
{
|
||
if (preg_match(self::REQUEST_PATTERN, $request, $m)) {
|
||
return [$m[1], $m[2], $m[3]];
|
||
}
|
||
|
||
return [null, null, null];
|
||
}
|
||
|
||
/**
|
||
* 判断 URI 是否为静态资源.
|
||
*
|
||
* 取 URI 路径部分的扩展名,命中 STATIC_EXTS 返回 true.
|
||
*/
|
||
public function isStaticResource(string $uri): bool
|
||
{
|
||
// 去掉 query string
|
||
$path = $uri;
|
||
$qsPos = strpos($path, '?');
|
||
if ($qsPos !== false) {
|
||
$path = substr($path, 0, $qsPos);
|
||
}
|
||
|
||
$dotPos = strrpos($path, '.');
|
||
if ($dotPos === false) {
|
||
return false;
|
||
}
|
||
|
||
$ext = strtolower(substr($path, $dotPos + 1));
|
||
|
||
return in_array($ext, self::STATIC_EXTS, true);
|
||
}
|
||
|
||
/**
|
||
* time_local 字符串 → Unix 时间戳.
|
||
*
|
||
* 格式示例:27/Jul/2026:16:20:15 +0000
|
||
* DateTime::createFromFormat 自动处理时区偏移,
|
||
* getTimestamp() 返回 UTC Unix 时间戳,
|
||
* 后续 date() 按业务时区(Asia/Shanghai)格式化.
|
||
*
|
||
* @param string $timeLocalStr nginx time_local 字段值
|
||
*
|
||
* @return int|null Unix 时间戳;解析失败返回 null
|
||
*/
|
||
protected function parseTimeLocal(string $timeLocalStr): ?int
|
||
{
|
||
$dt = DateTime::createFromFormat('d/M/Y:H:i:s O', $timeLocalStr);
|
||
if ($dt === false) {
|
||
return null;
|
||
}
|
||
|
||
return $dt->getTimestamp();
|
||
}
|
||
}
|