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 );
2026-06-14 18:30:06 +08:00
$fpRisk = self :: fetchFpRiskStats ( $conn , $where );
$conclusions = self :: buildConclusions ( $summary , $byReason , $byCountry , $byHour , $byDevice , $timing , $compare , $byResult , $fpRisk );
2026-06-14 14:00:24 +08:00
return [
'summary' => $summary ,
'by_result' => $byResult ,
'by_reason' => $byReason ,
'by_country' => $byCountry ,
'by_hour' => $byHour ,
'by_device' => $byDevice ,
'timing' => $timing ,
'compare' => $compare ,
2026-06-14 18:30:06 +08:00
'fp_risk' => $fpRisk ,
2026-06-14 14:00:24 +08:00
'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 ),
];
}
2026-06-14 18:30:06 +08:00
/**
* 二次风控(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 ,
];
}
2026-06-14 14:00:24 +08:00
/**
* @param array $summary
* @param array $byReason
* @param array $byCountry
* @param array $byHour
* @param array $byDevice
* @param array $timing
* @param array $compare
* @param array $byResult
2026-06-14 18:30:06 +08:00
* @param array $fpRisk
2026-06-14 14:00:24 +08:00
* @return string[]
*/
2026-06-14 18:30:06 +08:00
private static function buildConclusions ( array $summary , array $byReason , array $byCountry , array $byHour , array $byDevice , array $timing , array $compare , array $byResult , array $fpRisk = [])
2026-06-14 14:00:24 +08:00
{
$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 回调异常。' ;
}
2026-06-14 18:30:06 +08:00
$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 );
}
}
2026-06-14 14:00:24 +08:00
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 ;
}
}