Files
CLOAK/lib/Cloak/VisitorLogAnalytics.php
T

442 lines
16 KiB
PHP
Raw Normal View History

2026-06-14 14:00:24 +08:00
<?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;
}
}