Files
ulthon_admin/extend/base/tools/controller/timer/NginxLogImportBase.php
augushong f1a2e8fd5d fix(nginx-log): service 文件加 Service 后缀(命名规范合规)
F2 Code quality review 发现:3 个新增 service 未遵守
.agents/rules/ulthon-naming-convention.md 第 19 行规定
(service 模块文件名需带 Service 后缀)。

变更:
- 重命名 6 个文件(3 Base + 3 App):
  NginxLogParser(Base)→NginxLogParserService(Base)
  NginxLogReader(Base)→NginxLogReaderService(Base)
  NginxLogAggregator(Base)→NginxLogAggregatorService(Base)
- 同步类名、use、类型 hint、new 引用
- NginxLogReaderServiceBase 异常消息前缀同步加 Service
- controller Base 引用更新(NginxLogImportBase / NginxLogStatAggregateBase)

验证:
- php -l 全部 8 个文件语法通过
- grep 残留旧名 = 0 结果(24 处引用全部带 Service 后缀)
2026-07-28 06:57:13 +08:00

282 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace base\tools\controller\timer;
use app\admin\model\NginxAccessLog;
use app\common\controller\TimerController;
use think\facade\Log;
/**
* nginx 访问日志导入定时任务Base 层).
*
* 流程:
* 1. env 总开关校验(严格布尔判断,详见 notepad Task 2 #14
* 2. 读取 env 配置的日志文件清单
* 3. 依赖倒置:实例化 app 层 Parser / Reader
* 4. 每个文件首行格式自检 + 串行增量读取 + 批量入库
* 5. savePosition 写 parse_fail_count / parse_fail_samples
*
* 业务侧如需定制导入逻辑,重写 app/tools/controller/timer/NginxLogImport.php 对应方法即可拦截。
*/
class NginxLogImportBase extends TimerController
{
/**
* 防刷间隔(秒).
* 仅做控制器侧防刷,不影响定时器侧 frequency 节流。
*/
protected $frequency = 60;
/**
* 并发上限 cap单分片id=0
*/
protected $concurrency = 1;
/**
* 批量 insert chunk 大小。
*/
protected const INSERT_CHUNK = 500;
/**
* 单次读取最大行数(与 Reader 默认上限一致)。
*/
protected const READ_MAX_LINES = 100000;
/**
* parse_fail_samples 最大记录条数。
*/
protected const FAIL_SAMPLES_MAX = 10;
public function do()
{
// 1. 严格布尔判断env convert 'true'→true / 'false'→false
// 见 notepad Task 2 #14必须 !== true不能用 !== 'true' 字符串比较
if (env('nginx_log.enable') !== true) {
Log::debug('nginx_log disabled, skip');
return 'disabled';
}
// 2. 读日志文件清单(分号分隔)
$logFilesConfig = (string) env('nginx_log.log_files', '');
$files = array_filter(array_map('trim', explode(';', $logFilesConfig)), function ($f) {
return $f !== '';
});
if (empty($files)) {
return 'no_files';
}
// 3. 依赖倒置:使用 app 层入口类(业务侧可重写拦截)
$parser = new \app\common\service\NginxLogParserService();
$reader = new \app\common\service\NginxLogReaderService();
// 静态资源过滤开关
$excludeStatic = (int) sysconfig('nginx_log', 'exclude_static', 1) === 1;
$totalFiles = 0;
$totalLines = 0;
$totalFails = 0;
$errors = [];
foreach ($files as $file) {
try {
if (!is_file($file)) {
throw new \RuntimeException("文件不存在: {$file}");
}
// 4. 首行格式自检(避免对非 nginx 日志文件做无意义解析)
// 取首个非空行喂给 parser->isFormatMatch
$sampleLine = $this->readFirstNonEmptyLine($file);
if ($sampleLine === null || !$parser->isFormatMatch($sampleLine)) {
Log::error("nginx_log_import: 格式不匹配,跳过文件 {$file}");
$errors[] = "format_mismatch:{$file}";
continue;
}
$totalFiles++;
// 5. 串行处理:增量读取 + 解析 + 批量入库
$stats = $this->processFile($parser, $reader, $file, $excludeStatic);
$totalLines += $stats['lines'];
$totalFails += $stats['fails'];
} catch (\Throwable $e) {
Log::error("nginx_log_import: 处理文件异常 {$file} - " . $e->getMessage());
$errors[] = 'error:' . basename($file) . ':' . $e->getMessage();
}
}
return json_encode([
'files' => $totalFiles,
'lines' => $totalLines,
'fails' => $totalFails,
'errors' => $errors,
], JSON_UNESCAPED_UNICODE);
}
/**
* 处理单个文件增量读取、解析、过滤、批量入库、savePosition.
*
* @return array{lines:int,fails:int}
*/
protected function processFile(
\app\common\service\NginxLogParserService $parser,
\app\common\service\NginxLogReaderService $reader,
string $file,
bool $excludeStatic
): array {
$lines = 0;
$fails = 0;
$samples = [];
$batch = [];
$lastLineHash = null;
$inode = 0;
$offset = 0;
// Reader 是 Generator让其自然走完maxLines / EOF 会自动 savePosition
// 见 notepad Task 6 #2不要中途 break否则 maxLines/EOF 的 savePosition 都不执行
foreach ($reader->read($file, self::READ_MAX_LINES) as $line) {
// 跟踪最后处理的行(用于失败时 savePosition
$lastLineHash = md5($line);
$parsed = $parser->parse($line);
if ($parsed === null) {
$fails++;
if (count($samples) < self::FAIL_SAMPLES_MAX) {
$samples[] = mb_substr($line, 0, 500);
}
continue;
}
// 静态资源排除(在 parse 成功后判断,避免与 parse 失败混淆)
if ($excludeStatic && $parser->isStaticResource($parsed['uri'])) {
continue;
}
$batch[] = $this->buildRow($parsed, $file);
if (count($batch) >= self::INSERT_CHUNK) {
$this->insertBatch($batch);
$lines += count($batch);
$batch = [];
}
}
// flush 剩余
if (!empty($batch)) {
$this->insertBatch($batch);
$lines += count($batch);
$batch = [];
}
// 二次 savePosition把本批解析失败信息写到 positionReader 已经写过 offset这里只更新 fail 字段)
// 见 notepad Task 6 #3Reader 默认写 failCount=0本批若有失败需要覆盖
if ($fails > 0) {
$this->updateFailStats($reader, $file, $fails, $samples);
}
Log::info("nginx_log_import: 文件 {$file} 完成 lines={$lines} fails={$fails}");
return ['lines' => $lines, 'fails' => $fails];
}
/**
* 构造入库行:只保留 raw 表实际字段(过滤 parser 多余的 referer_domain/ua_type/ua_name
*
* raw 表字段(见 app/admin/scheme/NginxAccessLog.php
* file_path / remote_addr / remote_user / time_local / method / uri / query_string /
* http_version / status / body_bytes_sent / http_referer / http_user_agent /
* request_time / upstream_response_time / bytes_sent / country / province / city / create_time
*/
protected function buildRow(array $parsed, string $filePath): array
{
$now = time();
return [
'file_path' => $filePath,
'remote_addr' => $parsed['remote_addr'],
'remote_user' => $parsed['remote_user'] ?? '',
'time_local' => $parsed['time_local'],
'method' => $parsed['method'],
'uri' => $parsed['uri'],
'query_string' => $parsed['query_string'] ?? '',
'http_version' => $parsed['http_version'] ?? '',
'status' => $parsed['status'],
'body_bytes_sent' => $parsed['body_bytes_sent'],
'http_referer' => $parsed['http_referer'] ?? '',
'http_user_agent' => $parsed['http_user_agent'] ?? '',
'request_time' => $parsed['request_time'],
'upstream_response_time' => $parsed['upstream_response_time'],
'bytes_sent' => $parsed['bytes_sent'],
'country' => null,
'province' => null,
'city' => null,
'create_time' => $now,
];
}
/**
* 批量入库(使用 model 的 insertAll自动走默认连接 + 表前缀)。
*/
protected function insertBatch(array $batch): void
{
if (empty($batch)) {
return;
}
// NginxAccessLog::insertAll 走 modelschema 字段映射可控;
// chunk 由调用方控制INSERT_CHUNK=500避免单条 SQL 过大
NginxAccessLog::insertAll($batch);
}
/**
* 更新 position 的失败统计offset/inode 不变,仅覆盖 fail 字段)。
*
* 注意Reader 在 maxLines/EOF 已经 savePositionfailCount=0
* 这里取当前 position 后用相同 offset 重写,仅更新 failCount / failSamples。
*/
protected function updateFailStats(
\app\common\service\NginxLogReaderService $reader,
string $file,
int $fails,
array $samples
): void {
$position = $reader->getLastPosition($file);
if ($position === null) {
return; // Reader 已经保存过,这里取不到说明异常,放弃覆盖
}
$samplesJson = empty($samples) ? null : json_encode($samples, JSON_UNESCAPED_UNICODE);
$reader->savePosition(
$file,
(int) $position['inode'],
(int) $position['offset'],
$position['last_line_hash'] ?? null,
$fails,
$samplesJson
);
}
/**
* 读取文件首个非空行(用于格式自检).
*
* @return string|null 首个非空行trim 后),空文件返回 null
*/
protected function readFirstNonEmptyLine(string $file): ?string
{
$fp = @fopen($file, 'rb');
if ($fp === false) {
return null;
}
try {
while (($line = fgets($fp)) !== false) {
$line = trim($line);
if ($line !== '') {
return $line;
}
}
return null;
} finally {
if (is_resource($fp)) {
fclose($fp);
}
}
}
}