diff --git a/app/admin/controller/system/NginxLogStat.php b/app/admin/controller/system/NginxLogStat.php new file mode 100644 index 0000000..32aebd8 --- /dev/null +++ b/app/admin/controller/system/NginxLogStat.php @@ -0,0 +1,14 @@ +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(); + } + + /** + * 计算 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): 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; + } +}