diff --git a/app/common/service/NginxLogParser.php b/app/common/service/NginxLogParser.php new file mode 100644 index 0000000..b70ba07 --- /dev/null +++ b/app/common/service/NginxLogParser.php @@ -0,0 +1,14 @@ +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(); + } +}