442 lines
16 KiB
PHP
442 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|