Files
ulthon_admin/extend/base/admin/controller/system/NginxLogStatBase.php
augushong 7987001cd2 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 视图
2026-07-28 06:17:22 +08:00

366 lines
12 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
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;
}
}