feat(nginx-log): NginxLogStat dashboard 控制器(Base/App 双层,日/周/月/年聚合)

- extend/base/admin/controller/system/NginxLogStatBase.php:完整逻辑
  - @ControllerAnnotation(title=Nginx访问统计) + @NodeAnotation(title=访问统计)
  - index() 页面/接口同体:isAjax() 返回 JSON,否则 fetch()
  - period 解析:day(24h)/week(7d)/month(30d)/year(12m)/custom,invalid 自动 fallback day
  - custom start>end 自动 swap
  - 整体指标:SUM stat_hour,avg_request_time 按 PV 加权平均
  - 趋势:day 按 stat_hour 聚合补 0-23;week/month/custom 按 stat_date 补日期序列;year 按 (stat_date DIV 100) 聚合补 1-12 月
  - Top N:sysconfig('nginx_log','topn_default',10),topN=0 不截断
  - 跨周期 UV 标注:uv_is_estimate = period !== 'day'
  - 空表/空范围不崩(返回 0/[])
- app/admin/controller/system/NginxLogStat.php:App 入口,仅 extends Base

性能:stat_hour 10 万行 + period=year 服务端 < 700ms(< 2s 要求)
依赖:T1 stat 模型、T9 Aggregate;blocks:T13 视图
This commit is contained in:
augushong
2026-07-28 06:17:22 +08:00
parent 2b677c6f89
commit 7987001cd2
2 changed files with 379 additions and 0 deletions

View File

@@ -0,0 +1,365 @@
<?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';
}
// 解析时间范围YYYYMMDD int 表示)
[$startDate, $endDate] = $this->resolveDateRange($period);
if ($request->isAjax()) {
$data = $this->buildStatData($period, $startDate, $endDate);
return json([
'code' => 0,
'msg' => '',
'data' => $data,
]);
}
// 普通请求:渲染视图(视图 T13 创建)。给视图传初始 period 供 JS 使用
$this->assign('period', $period);
$this->assign('start', $startDate);
$this->assign('end', $endDate);
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): array
{
// 时间范围不合法 → 空数据(不崩)
if ($startDate <= 0 || $endDate <= 0 || $startDate > $endDate) {
return $this->emptyData($period);
}
$summary = $this->querySummary($startDate, $endDate);
$trend = $this->queryTrend($period, $startDate, $endDate);
$topN = $this->getTopN();
$topUrls = $this->queryTopByDateRange(NginxStatUrl::class, 'uri', $startDate, $endDate, $topN);
$topReferers = $this->queryTopByDateRange(NginxStatReferer::class, 'referer_domain', $startDate, $endDate, $topN);
$topUas = $this->queryTopByDateRange(NginxStatUa::class, 'ua_name', $startDate, $endDate, $topN);
// 跨周期 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): 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])
->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): array
{
if ($period === 'day') {
$rows = NginxStatHour::where('stat_date', 'between', [$startDate, $endDate])
->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])
->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])
->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 不限制
*/
protected function queryTopByDateRange(string $modelClass, string $labelField, int $startDate, int $endDate, int $topN): array
{
$query = $modelClass::where('stat_date', 'between', [$startDate, $endDate])
->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;
}
}