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; } }