Files
ulthon_admin/extend/base/admin/controller/system/NginxLogStatBase.php
augushong 8faa52ce90 feat(nginx-log): 多节点适配(node_id 全链路 + 全局/按节点聚合 + 节点筛选 + v2.4.0 升级脚本)
- 6 张表加 node_id 字段 + position/stat 改复合唯一键(migration)
- 6 个 Scheme 同步 node_id 注解 + 唯一键
- Reader 带 node_id 参数 + 懒加载接管(getLastPosition 回退查 node_id='')
- Aggregator 全局行(node_id='')+ 按节点循环 insertAggregatesForScope
- Stat 所有查询方法加 where node_id 条件
- Dashboard 控制器/视图加节点筛选下拉框 + AJAX 带 node_id
- AccessLog 列表加节点列 + 采集进度卡片显示节点信息
- import 定时任务 run_type 改 all(每节点采集自己的日志)
- v2.4.0 升级脚本追加 ALTER TABLE(幂等 check-then-alter)
- 菜单调整:去掉 Nginx 顶级菜单 + 读取位置管理,改为系统管理下挂两个子菜单
- ulthon-timer 文档补充 run_type=all 多节点部署注意事项
2026-08-08 10:33:20 +08:00

379 lines
13 KiB
PHP
Raw Permalink 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
namespace base\admin\controller\system;
use app\admin\model\NginxStatHour;
use app\admin\model\NginxStatReferer;
use app\admin\model\NginxStatUa;
use app\admin\model\NginxStatUrl;
use app\admin\service\annotation\ControllerAnnotation;
use app\admin\service\annotation\NodeAnotation;
use app\common\controller\AdminController;
use think\App;
/**
* Nginx 访问统计仪表盘Base 层:完整逻辑)
*
* 页面/接口同体:
* - 普通请求 → fetch() 渲染视图(视图由 T13 创建,本控制器只提供 fetch 入口)
* - AJAX 请求 → 返回 JSON整体指标 + 趋势 + Top URL/Referer/UA + uv_is_estimate flag
*
* @ControllerAnnotation(title="Nginx访问统计")
*/
class NginxLogStatBase extends AdminController
{
/**
* 合法的 period 取值
*/
protected array $validPeriods = ['day', 'week', 'month', 'year', 'custom'];
public function __construct(App $app)
{
parent::__construct($app);
}
/**
* @NodeAnotation(title="访问统计")
*/
public function index()
{
$request = $this->request;
$period = strtolower((string) $request->param('period', 'day'));
if (!in_array($period, $this->validPeriods, true)) {
// period=invalid → fallback 到 day
$period = 'day';
}
$nodeId = (string) $request->param('node_id', '');
// 解析时间范围YYYYMMDD int 表示)
[$startDate, $endDate] = $this->resolveDateRange($period);
if ($request->isAjax()) {
$data = $this->buildStatData($period, $startDate, $endDate, $nodeId);
return json([
'code' => 0,
'msg' => '',
'data' => $data,
]);
}
// 普通请求:渲染视图(视图 T13 创建)。给视图传初始 period 供 JS 使用
$this->assign('period', $period);
$this->assign('start', $startDate);
$this->assign('end', $endDate);
// 在线节点列表(供视图渲染节点筛选下拉)
$nodes = \app\admin\model\SystemHost::where('status', 1)->order('node_id', 'asc')->select();
$this->assign('nodes', $nodes);
$this->assign('node_id', $nodeId);
return $this->fetch();
}
/**
* 计算 topNtopn_default=0 表示不截断
*/
protected function getTopN(): int
{
$topn = (int) sysconfig('nginx_log', 'topn_default', 10);
if ($topn < 0) {
$topn = 0;
}
return $topn;
}
/**
* 解析时间范围,返回 [startDate, endDate]YYYYMMDD int
* 规则:
* - day: 当天
* - week: 最近 7 天(含今天)
* - month: 最近 30 天(含今天)
* - year: 本年 1月1日 ~ 12月31日
* - custom: 使用 start/end若 start>end 自动 swap
*/
protected function resolveDateRange(string $period): array
{
$todayTs = strtotime(date('Y-m-d'));
$todayYmd = (int) date('Ymd');
if ($period === 'custom') {
$startInput = (string) $this->request->param('start', '');
$endInput = (string) $this->request->param('end', '');
$startTs = $startInput !== '' ? strtotime($startInput) : $todayTs;
$endTs = $endInput !== '' ? strtotime($endInput) : $todayTs;
// 解析失败回退到今天
if ($startTs === false) {
$startTs = $todayTs;
}
if ($endTs === false) {
$endTs = $todayTs;
}
// start>end → 自动 swap
if ($startTs > $endTs) {
[$startTs, $endTs] = [$endTs, $startTs];
}
return [(int) date('Ymd', $startTs), (int) date('Ymd', $endTs)];
}
switch ($period) {
case 'week':
$startTs = strtotime('-6 days', $todayTs);
$endTs = $todayTs;
break;
case 'month':
$startTs = strtotime('-29 days', $todayTs);
$endTs = $todayTs;
break;
case 'year':
$startTs = strtotime((string) date('Y') . '-01-01');
$endTs = strtotime((string) date('Y') . '-12-31');
break;
case 'day':
default:
$startTs = $todayTs;
$endTs = $todayTs;
break;
}
return [(int) date('Ymd', $startTs), (int) date('Ymd', $endTs)];
}
/**
* 组装完整统计数据
*/
protected function buildStatData(string $period, int $startDate, int $endDate, string $nodeId = ''): array
{
// 时间范围不合法 → 空数据(不崩)
if ($startDate <= 0 || $endDate <= 0 || $startDate > $endDate) {
return $this->emptyData($period);
}
$summary = $this->querySummary($startDate, $endDate, $nodeId);
$trend = $this->queryTrend($period, $startDate, $endDate, $nodeId);
$topN = $this->getTopN();
$topUrls = $this->queryTopByDateRange(NginxStatUrl::class, 'uri', $startDate, $endDate, $topN, $nodeId);
$topReferers = $this->queryTopByDateRange(NginxStatReferer::class, 'referer_domain', $startDate, $endDate, $topN, $nodeId);
$topUas = $this->queryTopByDateRange(NginxStatUa::class, 'ua_name', $startDate, $endDate, $topN, $nodeId);
// 跨周期 UV 估算标注:仅 day 为精确值week/month/year/custom 均为按日累加的估算值
$uvIsEstimate = $period !== 'day';
return [
'period' => $period,
'start' => $startDate,
'end' => $endDate,
'summary' => $summary,
'trend' => $trend,
'top_urls' => $topUrls,
'top_referers' => $topReferers,
'top_uas' => $topUas,
'uv_is_estimate' => $uvIsEstimate,
];
}
/**
* 空数据返回(不崩)
*/
protected function emptyData(string $period): array
{
return [
'period' => $period,
'start' => 0,
'end' => 0,
'summary' => $this->emptySummary(),
'trend' => [],
'top_urls' => [],
'top_referers' => [],
'top_uas' => [],
'uv_is_estimate' => $period !== 'day',
];
}
protected function emptySummary(): array
{
return [
'pv' => 0,
'uv' => 0,
'total_bytes' => 0,
'status_2xx' => 0,
'status_3xx' => 0,
'status_4xx' => 0,
'status_5xx' => 0,
'status_other' => 0,
'avg_request_time' => 0.0,
];
}
/**
* 整体指标SUM stat_hour
* avg_request_time 按 PV 加权平均(避免简单 AVG 被低 PV 时段拉偏)
*/
protected function querySummary(int $startDate, int $endDate, string $nodeId = ''): array
{
$sumFields = [
'IFNULL(SUM(pv),0) AS pv',
'IFNULL(SUM(uv),0) AS uv',
'IFNULL(SUM(total_bytes),0) AS total_bytes',
'IFNULL(SUM(status_2xx),0) AS status_2xx',
'IFNULL(SUM(status_3xx),0) AS status_3xx',
'IFNULL(SUM(status_4xx),0) AS status_4xx',
'IFNULL(SUM(status_5xx),0) AS status_5xx',
'IFNULL(SUM(status_other),0) AS status_other',
'IFNULL(SUM(avg_request_time * pv),0) AS weighted_time',
];
$row = NginxStatHour::where('stat_date', 'between', [$startDate, $endDate])
->where('node_id', $nodeId)
->field(implode(',', $sumFields))
->find();
$pv = (int) ($row['pv'] ?? 0);
$weighted = (float) ($row['weighted_time'] ?? 0);
$avg = $pv > 0 ? ($weighted / $pv) : 0.0;
return [
'pv' => $pv,
'uv' => (int) ($row['uv'] ?? 0),
'total_bytes' => (int) ($row['total_bytes'] ?? 0),
'status_2xx' => (int) ($row['status_2xx'] ?? 0),
'status_3xx' => (int) ($row['status_3xx'] ?? 0),
'status_4xx' => (int) ($row['status_4xx'] ?? 0),
'status_5xx' => (int) ($row['status_5xx'] ?? 0),
'status_other' => (int) ($row['status_other'] ?? 0),
'avg_request_time' => round($avg, 4),
];
}
/**
* 趋势:按 hour/day/month 聚合,并补全空点
*
* - day → 当天 24 小时(按 stat_hour 分组,补全 0~23
* - week → 7 天(按 stat_date 分组,补全日期序列)
* - month→ ~30 天(按 stat_date 分组,补全日期序列)
* - year → 12 个月(按 stat_date DIV 100 分组,补全 1~12 月)
* - custom→ 按 stat_date 分组,补全 start~end 日期序列
*/
protected function queryTrend(string $period, int $startDate, int $endDate, string $nodeId = ''): array
{
if ($period === 'day') {
$rows = NginxStatHour::where('stat_date', 'between', [$startDate, $endDate])
->where('node_id', $nodeId)
->field('stat_hour, IFNULL(SUM(pv),0) AS pv, IFNULL(SUM(uv),0) AS uv')
->group('stat_hour')
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$map[(int) $r['stat_hour']] = $r;
}
$trend = [];
for ($h = 0; $h < 24; $h++) {
$trend[] = [
'label' => sprintf('%02d:00', $h),
'pv' => (int) ($map[$h]['pv'] ?? 0),
'uv' => (int) ($map[$h]['uv'] ?? 0),
];
}
return $trend;
}
if ($period === 'year') {
$rows = NginxStatHour::where('stat_date', 'between', [$startDate, $endDate])
->where('node_id', $nodeId)
->field('(stat_date DIV 100) AS ym, IFNULL(SUM(pv),0) AS pv, IFNULL(SUM(uv),0) AS uv')
->group('ym')
->order('ym asc')
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$map[(int) $r['ym']] = $r;
}
$trend = [];
$year = (int) substr((string) $startDate, 0, 4);
for ($m = 1; $m <= 12; $m++) {
$ym = $year * 100 + $m;
$trend[] = [
'label' => sprintf('%04d-%02d', $year, $m),
'pv' => (int) ($map[$ym]['pv'] ?? 0),
'uv' => (int) ($map[$ym]['uv'] ?? 0),
];
}
return $trend;
}
// week / month / custom → 按 stat_date 分组,补全日期序列
$rows = NginxStatHour::where('stat_date', 'between', [$startDate, $endDate])
->where('node_id', $nodeId)
->field('stat_date, IFNULL(SUM(pv),0) AS pv, IFNULL(SUM(uv),0) AS uv')
->group('stat_date')
->order('stat_date asc')
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$map[(int) $r['stat_date']] = $r;
}
$trend = [];
$startTs = strtotime(substr((string) $startDate, 0, 8));
$endTs = strtotime(substr((string) $endDate, 0, 8));
for ($ts = $startTs; $ts <= $endTs; $ts = strtotime('+1 day', $ts)) {
$ymd = (int) date('Ymd', $ts);
$trend[] = [
'label' => date('Y-m-d', $ts),
'pv' => (int) ($map[$ymd]['pv'] ?? 0),
'uv' => (int) ($map[$ymd]['uv'] ?? 0),
];
}
return $trend;
}
/**
* 查 Top N按 pv 排序)
* topN=0 表示不截断(保留全部)
*
* @param string $modelClass 模型类(依赖倒置:调 app/admin/model 入口)
* @param string $labelField 分组与展示的字段名
* @param int $topN 取前 N 条0 不限制
* @param string $nodeId 节点 ID空字符串=全局聚合行)
*/
protected function queryTopByDateRange(string $modelClass, string $labelField, int $startDate, int $endDate, int $topN, string $nodeId = ''): array
{
$query = $modelClass::where('stat_date', 'between', [$startDate, $endDate])
->where('node_id', $nodeId)
->field($labelField . ' AS label, IFNULL(SUM(pv),0) AS pv, IFNULL(SUM(uv),0) AS uv')
->group($labelField)
->order('pv desc');
if ($topN > 0) {
$query->limit($topN);
}
$rows = $query->select()->toArray();
// 强制 int 类型MySQL SUM 默认返回 string
foreach ($rows as &$r) {
$r['pv'] = (int) $r['pv'];
$r['uv'] = (int) $r['uv'];
}
unset($r);
return $rows;
}
}