Files
CLOAK/lib/Cloak/VisitorLogAnalytics.php
T
2026-06-14 14:00:24 +08:00

442 lines
16 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
/**
* visitor_logs 聚合分析与结论生成
*/
require_once dirname(__DIR__) . '/DbHelper.php';
require_once __DIR__ . '/VisitorLogSchema.php';
class VisitorLogAnalytics
{
/**
* @param mysqli $conn
* @param array $filters date_from, date_to, domain, campagin_id
* @return array
*/
public static function buildReport($conn, array $filters = [])
{
VisitorLogSchema::ensureColumns($conn);
VisitorLogSchema::ensureIndexes($conn);
$where = self::buildWhere($conn, $filters);
$summary = self::fetchSummary($conn, $where);
$byResult = self::fetchGroup($conn, $where, 'result', 'label');
$byReason = self::fetchReasonTop($conn, $where, 15);
$byCountry = self::fetchGroup($conn, $where, 'country', 'country', 10);
$byHour = self::fetchByHour($conn, $where);
$byDevice = self::fetchDeviceTop($conn, $where, 8);
$timing = self::fetchTimingStats($conn, $where);
$compare = self::fetchCompare($conn, $filters, $summary);
$conclusions = self::buildConclusions($summary, $byReason, $byCountry, $byHour, $byDevice, $timing, $compare, $byResult);
return [
'summary' => $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;
}
}