mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-31 05:05:33 +08:00
- 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 多节点部署注意事项
379 lines
13 KiB
PHP
379 lines
13 KiB
PHP
<?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();
|
||
}
|
||
|
||
/**
|
||
* 计算 topN:topn_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;
|
||
}
|
||
}
|