$summary, 'by_result' => $byResult, 'by_reason' => $byReason, 'by_country' => $byCountry, 'by_hour' => $byHour, 'by_device' => $byDevice, 'timing' => $timing, 'compare' => $compare, 'conclusions' => $conclusions, ]; } /** * @param mysqli $conn * @param array $filters */ private static function buildWhere($conn, array $filters) { $parts = ['1=1']; $dateFrom = trim((string) ($filters['date_from'] ?? '')); $dateTo = trim((string) ($filters['date_to'] ?? '')); if ($dateFrom === '' && $dateTo === '') { $dateTo = date('Y-m-d'); $dateFrom = date('Y-m-d', strtotime('-6 days')); } if ($dateFrom !== '') { $parts[] = "`visit_date` >= '" . cloak_db_escape($conn, $dateFrom . ' 00:00:00') . "'"; } if ($dateTo !== '') { $parts[] = "`visit_date` <= '" . cloak_db_escape($conn, $dateTo . ' 23:59:59') . "'"; } $domain = trim((string) ($filters['domain'] ?? '')); if ($domain !== '') { $parts[] = "`domain` LIKE '%" . cloak_db_escape($conn, $domain) . "%'"; } $campaginId = trim((string) ($filters['campagin_id'] ?? '')); if ($campaginId !== '') { $parts[] = "`campagin_id` = '" . cloak_db_escape($conn, $campaginId) . "'"; } return implode(' AND ', $parts); } /** * @param mysqli $conn * @param string $where */ private static function fetchSummary($conn, $where) { $sql = "SELECT COUNT(*) AS total, SUM(CASE WHEN `result` = 'true' THEN 1 ELSE 0 END) AS pass_cnt, SUM(CASE WHEN `result` = 'false' THEN 1 ELSE 0 END) AS block_cnt, SUM(CASE WHEN `result` = 'wait' THEN 1 ELSE 0 END) AS wait_cnt FROM `visitor_logs` WHERE {$where}"; $res = $conn->query($sql); $row = $res ? $res->fetch_assoc() : []; $total = (int) ($row['total'] ?? 0); $pass = (int) ($row['pass_cnt'] ?? 0); $block = (int) ($row['block_cnt'] ?? 0); $wait = (int) ($row['wait_cnt'] ?? 0); $decided = max(1, $pass + $block); return [ 'total' => $total, 'pass' => $pass, 'block' => $block, 'wait' => $wait, 'pass_rate' => $total > 0 ? round($pass / $decided * 100, 1) : 0, 'block_rate' => $total > 0 ? round($block / $decided * 100, 1) : 0, 'wait_rate' => $total > 0 ? round($wait / $total * 100, 1) : 0, ]; } /** * @param mysqli $conn * @param string $where * @param string $column * @param string $labelKey * @param int $limit */ private static function fetchGroup($conn, $where, $column, $labelKey, $limit = 0) { $limitSql = $limit > 0 ? ' LIMIT ' . (int) $limit : ''; $sql = "SELECT `{$column}` AS grp, COUNT(*) AS cnt FROM `visitor_logs` WHERE {$where} GROUP BY `{$column}` ORDER BY cnt DESC{$limitSql}"; $res = $conn->query($sql); $rows = []; if ($res) { while ($r = $res->fetch_assoc()) { $label = trim((string) ($r['grp'] ?? '')); if ($label === '') { $label = '(空)'; } if ($column === 'result') { if ($label === 'true') { $label = '通过'; } elseif ($label === 'false') { $label = '拦截'; } elseif ($label === 'wait') { $label = '检测中'; } } $rows[] = [ $labelKey => $label, 'count' => (int) $r['cnt'], ]; } $res->free(); } return $rows; } /** * @param mysqli $conn * @param string $where * @param int $limit */ private static function fetchReasonTop($conn, $where, $limit) { $limit = (int) $limit; $sql = "SELECT IFNULL(NULLIF(TRIM(`reason`), ''), '(无理由/通过)') AS reason, COUNT(*) AS cnt FROM `visitor_logs` WHERE {$where} AND (`result` = 'false' OR `result` = 'wait') GROUP BY reason ORDER BY cnt DESC LIMIT {$limit}"; $res = $conn->query($sql); $rows = []; $total = 0; if ($res) { while ($r = $res->fetch_assoc()) { $cnt = (int) $r['cnt']; $total += $cnt; $rows[] = [ 'reason' => (string) $r['reason'], 'count' => $cnt, ]; } $res->free(); } foreach ($rows as &$row) { $row['pct'] = $total > 0 ? round($row['count'] / $total * 100, 1) : 0; } unset($row); return $rows; } /** * @param mysqli $conn * @param string $where */ private static function fetchByHour($conn, $where) { $sql = "SELECT HOUR(`visit_date`) AS hr, COUNT(*) AS cnt FROM `visitor_logs` WHERE {$where} GROUP BY hr ORDER BY hr ASC"; $res = $conn->query($sql); $map = array_fill(0, 24, 0); if ($res) { while ($r = $res->fetch_assoc()) { $hr = (int) $r['hr']; if ($hr >= 0 && $hr <= 23) { $map[$hr] = (int) $r['cnt']; } } $res->free(); } $rows = []; for ($h = 0; $h < 24; $h++) { $rows[] = ['hour' => sprintf('%02d:00', $h), 'count' => $map[$h]]; } return $rows; } /** * @param mysqli $conn * @param string $where * @param int $limit */ private static function fetchDeviceTop($conn, $where, $limit) { $limit = (int) $limit; $sql = "SELECT IFNULL(NULLIF(TRIM(`device`), ''), '未知') AS device, COUNT(*) AS cnt FROM `visitor_logs` WHERE {$where} GROUP BY device ORDER BY cnt DESC LIMIT {$limit}"; $res = $conn->query($sql); $rows = []; if ($res) { while ($r = $res->fetch_assoc()) { $rows[] = [ 'device' => (string) $r['device'], 'count' => (int) $r['cnt'], ]; } $res->free(); } return $rows; } /** * @param mysqli $conn * @param string $where */ private static function fetchTimingStats($conn, $where) { $sql = "SELECT `judge_timing` FROM `visitor_logs` WHERE {$where} AND `judge_timing` IS NOT NULL AND `judge_timing` <> '' ORDER BY `id` DESC LIMIT 500"; $res = $conn->query($sql); $stageTotals = []; $stageCounts = []; $totalMsSum = 0; $totalMsCnt = 0; if ($res) { while ($r = $res->fetch_assoc()) { $decoded = json_decode((string) ($r['judge_timing'] ?? ''), true); if (!is_array($decoded)) { continue; } if (isset($decoded['total_ms'])) { $totalMsSum += (float) $decoded['total_ms']; $totalMsCnt++; } if (!empty($decoded['stages']) && is_array($decoded['stages'])) { foreach ($decoded['stages'] as $stage) { if (!is_array($stage)) { continue; } $name = trim((string) ($stage['name'] ?? '')); if ($name === '') { continue; } $ms = (float) ($stage['ms'] ?? 0); if (!isset($stageTotals[$name])) { $stageTotals[$name] = 0; $stageCounts[$name] = 0; } $stageTotals[$name] += $ms; $stageCounts[$name]++; } } } $res->free(); } $stages = []; foreach ($stageTotals as $name => $sum) { $cnt = max(1, $stageCounts[$name]); $stages[] = [ 'stage' => $name, 'avg_ms' => round($sum / $cnt, 2), ]; } usort($stages, static function ($a, $b) { return $b['avg_ms'] <=> $a['avg_ms']; }); return [ 'avg_total_ms' => $totalMsCnt > 0 ? round($totalMsSum / $totalMsCnt, 2) : 0, 'sample_size' => $totalMsCnt, 'stages' => $stages, ]; } /** * @param mysqli $conn * @param array $filters * @param array $currentSummary */ private static function fetchCompare($conn, array $filters, array $currentSummary) { $dateFrom = trim((string) ($filters['date_from'] ?? '')); $dateTo = trim((string) ($filters['date_to'] ?? '')); if ($dateFrom === '' || $dateTo === '') { $dateTo = date('Y-m-d'); $dateFrom = date('Y-m-d', strtotime('-6 days')); } $start = strtotime($dateFrom . ' 00:00:00'); $end = strtotime($dateTo . ' 23:59:59'); if ($start === false || $end === false || $end < $start) { return [ 'prev_total' => 0, 'pass_rate_delta' => 0, 'block_rate_delta' => 0, ]; } $days = max(1, (int) floor(($end - $start) / 86400) + 1); $prevEnd = date('Y-m-d 23:59:59', $start - 86400); $prevStart = date('Y-m-d 00:00:00', $start - ($days * 86400)); $prevFilters = $filters; $prevFilters['date_from'] = substr($prevStart, 0, 10); $prevFilters['date_to'] = substr($prevEnd, 0, 10); $prevWhere = self::buildWhere($conn, $prevFilters); $prevSummary = self::fetchSummary($conn, $prevWhere); return [ 'prev_total' => (int) $prevSummary['total'], 'pass_rate_delta' => round($currentSummary['pass_rate'] - $prevSummary['pass_rate'], 1), 'block_rate_delta' => round($currentSummary['block_rate'] - $prevSummary['block_rate'], 1), ]; } /** * @param array $summary * @param array $byReason * @param array $byCountry * @param array $byHour * @param array $byDevice * @param array $timing * @param array $compare * @param array $byResult * @return string[] */ private static function buildConclusions(array $summary, array $byReason, array $byCountry, array $byHour, array $byDevice, array $timing, array $compare, array $byResult) { $lines = []; $total = (int) $summary['total']; if ($total === 0) { $lines[] = '所选时间范围内暂无访客日志,请调整筛选条件或确认异步队列已落库。'; return $lines; } $lines[] = sprintf( '共记录 %d 次访问,通过率 %.1f%%,拦截率 %.1f%%,检测中占比 %.1f%%。', $total, $summary['pass_rate'], $summary['block_rate'], $summary['wait_rate'] ); if ($compare['prev_total'] > 0) { $deltaSign = $compare['pass_rate_delta'] >= 0 ? '上升' : '下降'; $lines[] = sprintf( '与上一同等周期(%d 条)相比,通过率%s %.1f 个百分点,拦截率变化 %.1f 个百分点。', $compare['prev_total'], $deltaSign, abs($compare['pass_rate_delta']), $compare['block_rate_delta'] ); } if ($byReason !== []) { $top = $byReason[0]; $lines[] = sprintf( '主要拦截/待判定理由为「%s」,占该类记录 %.1f%%(%d 次)。', $top['reason'], $top['pct'], $top['count'] ); if (count($byReason) >= 2) { $second = $byReason[1]; $lines[] = sprintf('次常见理由:「%s」(%d 次,%.1f%%)。', $second['reason'], $second['count'], $second['pct']); } } $peakHour = null; $peakCnt = 0; foreach ($byHour as $h) { if ($h['count'] > $peakCnt) { $peakCnt = $h['count']; $peakHour = $h['hour']; } } if ($peakHour !== null && $peakCnt > 0) { $lines[] = sprintf('访问高峰时段为 %s,共 %d 次访问。', $peakHour, $peakCnt); } if ($byCountry !== []) { $topC = $byCountry[0]; $countryLabel = $topC['country'] === '(空)' ? '未知国家' : $topC['country']; $lines[] = sprintf('访客来源以「%s」最多,共 %d 次。', $countryLabel, $topC['count']); } if ($byDevice !== []) { $lines[] = sprintf('设备分布首位:%s(%d 次)。', $byDevice[0]['device'], $byDevice[0]['count']); } if ($timing['sample_size'] > 0) { $lines[] = sprintf('判定耗时抽样 %d 条,平均总耗时 %.2f ms。', $timing['sample_size'], $timing['avg_total_ms']); if (!empty($timing['stages'])) { $slow = $timing['stages'][0]; $lines[] = sprintf('最慢阶段为「%s」,平均 %.2f ms。', $slow['stage'], $slow['avg_ms']); } } if ($summary['block_rate'] > 60) { $lines[] = '告警:拦截率超过 60%,建议检查 API 配置、黑白名单及广告来源规则是否过严。'; } if ($summary['wait_rate'] > 5) { $lines[] = '告警:检测中(wait)状态占比偏高,可能存在二次风控未完成或 f_check 回调异常。'; } foreach (array_slice($byReason, 0, 3) as $r) { if ($r['pct'] >= 40 && $total >= 20) { $lines[] = sprintf('关注:理由「%s」占比达 %.1f%%,建议针对性排查该规则。', $r['reason'], $r['pct']); break; } } return $lines; } }