feat: initial commit
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user