Files
CLOAK/lib/Cloak/VisitorLogAnalytics.php
T
2026-06-15 22:42:59 +08:00

503 lines
19 KiB
PHP
Executable File
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);
$fpRisk = self::fetchFpRiskStats($conn, $where);
$conclusions = self::buildConclusions($summary, $byReason, $byCountry, $byHour, $byDevice, $timing, $compare, $byResult, $fpRisk);
return [
'summary' => $summary,
'by_result' => $byResult,
'by_reason' => $byReason,
'by_country' => $byCountry,
'by_hour' => $byHour,
'by_device' => $byDevice,
'timing' => $timing,
'compare' => $compare,
'fp_risk' => $fpRisk,
'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),
];
}
/**
* 二次风控(byApiRisk)相关聚合:数字 reason、fp_hdata 覆盖率等。
*
* @param mysqli $conn
* @param string $where
*/
private static function fetchFpRiskStats($conn, $where)
{
$sql = "SELECT
SUM(CASE WHEN `reason` REGEXP '^[0-9]+$' OR `reason` REGEXP '^[0-9]+[[:space:]*:|/]' THEN 1 ELSE 0 END) AS score_reason_cnt,
SUM(CASE WHEN `fp_hdata` IS NOT NULL AND TRIM(`fp_hdata`) <> '' THEN 1 ELSE 0 END) AS hdata_cnt,
SUM(CASE WHEN `result` = 'false' AND (`reason` REGEXP '^[0-9]+$' OR `reason` REGEXP '风险|WebDriver|Canvas|自动化|指纹' OR `reason` REGEXP '^[0-9]+[[:space:]*:|/]') THEN 1 ELSE 0 END) AS fp_block_cnt,
SUM(CASE WHEN `result` = 'true' AND (`reason` REGEXP '^[0-9]+$' OR `reason` REGEXP '^[0-9]+[[:space:]*:|/]') THEN 1 ELSE 0 END) AS fp_pass_cnt,
AVG(CASE WHEN `reason` REGEXP '^[0-9]+$' THEN CAST(`reason` AS UNSIGNED) ELSE NULL END) AS avg_score_reason
FROM `visitor_logs` WHERE {$where}";
$res = $conn->query($sql);
$row = $res ? $res->fetch_assoc() : [];
return [
'score_reason_cnt' => (int) ($row['score_reason_cnt'] ?? 0),
'hdata_cnt' => (int) ($row['hdata_cnt'] ?? 0),
'fp_block_cnt' => (int) ($row['fp_block_cnt'] ?? 0),
'fp_pass_cnt' => (int) ($row['fp_pass_cnt'] ?? 0),
'avg_score_reason' => $row['avg_score_reason'] !== null ? round((float) $row['avg_score_reason'], 1) : null,
];
}
/**
* @param array $summary
* @param array $byReason
* @param array $byCountry
* @param array $byHour
* @param array $byDevice
* @param array $timing
* @param array $compare
* @param array $byResult
* @param array $fpRisk
* @return string[]
*/
private static function buildConclusions(array $summary, array $byReason, array $byCountry, array $byHour, array $byDevice, array $timing, array $compare, array $byResult, array $fpRisk = [])
{
$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 回调异常。';
}
$total = (int) $summary['total'];
$hdataCnt = (int) ($fpRisk['hdata_cnt'] ?? 0);
$scoreReasonCnt = (int) ($fpRisk['score_reason_cnt'] ?? 0);
if ($total > 0 && $hdataCnt > 0) {
$hdataPct = round($hdataCnt / $total * 100, 1);
$lines[] = sprintf(
'二次风控:%d 条含指纹 hdata(占 %.1f%%),%d 条 reason 为风险分格式。',
$hdataCnt,
$hdataPct,
$scoreReasonCnt
);
if ($hdataPct < 30 && $total >= 20) {
$lines[] = '关注:指纹 hdata 覆盖率偏低,请检查 CLOAK_RISK_NUMBER 是否为 0、cloakjs 是否加载,或加强判断是否导致采集失败。';
}
}
if ($scoreReasonCnt >= 10 && ($fpRisk['avg_score_reason'] ?? null) !== null) {
$avg = $fpRisk['avg_score_reason'];
$lines[] = sprintf('指纹风险分 reason 平均约 %.1f 分(纯数字 reason 样本)。', $avg);
if ($avg >= 60) {
$lines[] = '关注:平均风险分偏高,若同时误拦真实用户,基础模式建议将风险系数调至 68 左右(加强模式 52)。';
} elseif ($avg <= 25 && ($fpRisk['fp_block_cnt'] ?? 0) === 0 && $scoreReasonCnt >= 20) {
$lines[] = '提示:风险分普遍较低且少见拦截,若需更严防护可适当降低风险系数或开启加强判断。';
}
}
if (($fpRisk['fp_block_cnt'] ?? 0) >= 5 && $total >= 20) {
$fpBlockRate = round($fpRisk['fp_block_cnt'] / max(1, $scoreReasonCnt) * 100, 1);
if ($fpBlockRate >= 50 && $scoreReasonCnt >= 10) {
$lines[] = sprintf('告警:指纹 reason 类拦截占指纹记录约 %.1f%%,建议抽样「判定详情」检查是否阈值过低(旧值 51 或加强模式阈值过高)。', $fpBlockRate);
}
}
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;
}
}