风险系数判断修复

This commit is contained in:
root
2026-06-14 18:30:06 +08:00
parent 235bf939b8
commit eb2724e3de
143 changed files with 68315 additions and 73 deletions
+627
View File
@@ -0,0 +1,627 @@
<?php
/**
* 二次风控(byApiRisk)判定过程解析:结合 reason、配置阈值与 fp_hdata 字段标注依据。
*/
class FpRiskExplain
{
/** @var bool */
private static $analyzerLoaded = false;
/**
* @param string|array|null $hdataRaw JSON 或已解码数组
* @param string $reason visitor_logs.reasonAPI 返回)
* @param string $result visitor_logs.result true/false/wait
* @param string $domain 用于读取站点 CLOAK_RISK_NUMBER
* @return array{summary:string,html:string,data:array}
*/
public static function build($hdataRaw, $reason, $result, $domain = '')
{
$hdata = self::decodeHdata($hdataRaw);
$reason = trim((string) $reason);
$result = strtolower(trim((string) $result));
$cfg = self::readRiskConfigForDomain($domain);
$parsedReason = self::parseApiReason($reason);
$local = self::runLocalAnalyzer($hdata);
$localLevel = isset($local['risk_level']) ? (int) $local['risk_level'] : null;
$apiLevel = $parsedReason['risk_level'];
$riskLevel = $localLevel !== null ? $localLevel : $apiLevel;
$factors = !empty($local['risk_factors']) ? $local['risk_factors'] : $parsedReason['factors'];
$riskNumber = (int) $cfg['risk_number'];
$configEnhanced = strtoupper($cfg['risk_enhanced']) === 'ON';
$hdataEnhanced = self::isEnhancedHdata($hdata);
$enhanced = $hdataEnhanced || $configEnhanced;
if ($riskNumber <= 0) {
$verdictText = '二次风控已关闭(风险系数=0)';
$compareText = '未与 API 阈值比较';
$expectedBlocked = null;
} else {
$expectedBlocked = ($riskLevel !== null && $riskLevel >= $riskNumber);
$verdictText = $expectedBlocked ? '应拦截 → 安全页' : '应放行 → 真实页';
$compareText = ($riskLevel !== null)
? sprintf('%d %s %d → %s', $riskLevel, $expectedBlocked ? '>=' : '<', $riskNumber, $expectedBlocked ? '拦截' : '放行')
: '未能解析风险分';
}
$actualPass = ($result === 'true' || $result === '1');
$actualText = $result === 'wait' || $result === '检测中'
? '检测中'
: ($actualPass ? '实际:放行(TRUE)' : '实际:拦截(FALSE)');
$factorRows = self::buildFactorRows($factors, $hdata);
$fieldMap = self::collectHighlightedFields($factorRows);
$data = [
'risk_level' => $riskLevel,
'local_risk_level' => $localLevel,
'api_risk_level' => $apiLevel,
'risk_number' => $riskNumber,
'recommended_risk' => self::getRecommendedRiskNumber($configEnhanced),
'risk_enhanced' => $enhanced,
'config_enhanced' => $configEnhanced,
'hdata_enhanced' => $hdataEnhanced,
'config_name' => $cfg['config_name'],
'verdict_text' => $verdictText,
'compare_text' => $compareText,
'expected_blocked' => $expectedBlocked,
'actual_result' => $result,
'actual_pass' => $actualPass,
'actual_text' => $actualText,
'api_reason' => $reason,
'parsed_reason' => $parsedReason,
'local_analysis' => $local,
'factors' => $factorRows,
'highlight_fields' => array_keys($fieldMap),
'field_values' => $fieldMap,
'config_edit_url' => 'dashboard.php?action=edit_config&name=' . rawurlencode($cfg['config_name']),
];
$data['conclusions'] = self::buildAnalysisConclusions($data, $hdata);
$summary = self::buildSummary($data);
$html = self::buildHtml($data, $hdata);
return [
'summary' => $summary,
'html' => $html,
'data' => $data,
];
}
private static function isEnhancedHdata(array $hdata): bool
{
if (!empty($hdata['risk_enhanced'])) {
return true;
}
return !empty($hdata['canvas']) && is_array($hdata['canvas'])
&& !empty($hdata['webgl']) && is_array($hdata['webgl']);
}
private static function getRecommendedRiskNumber(bool $configEnhanced): int
{
return $configEnhanced ? 52 : 68;
}
private static function decodeHdata($raw)
{
if (is_array($raw)) {
return $raw;
}
$raw = trim((string) $raw);
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
private static function loadAnalyzer()
{
if (!self::$analyzerLoaded) {
require_once dirname(__DIR__, 2) . '/__code__/risk_analyzer_new.php';
self::$analyzerLoaded = true;
}
}
private static function runLocalAnalyzer(array $hdata)
{
if ($hdata === []) {
return [];
}
self::loadAnalyzer();
try {
$analyzer = new RiskAnalyzer($hdata);
return $analyzer->analyze();
} catch (Throwable $e) {
return ['error' => $e->getMessage()];
}
}
/**
* 从 config 文件读取阈值,避免 datalist 循环 include 污染常量。
*/
public static function readRiskConfigForDomain($domain)
{
$baseDir = dirname(__DIR__, 2);
$host = parse_url((string) $domain, PHP_URL_HOST);
if ($host === null || $host === '') {
$host = preg_replace('#^https?://#i', '', (string) $domain);
$host = explode('/', $host)[0];
}
if (!class_exists('DomainRepository', false)) {
require_once __DIR__ . '/DomainRepository.php';
}
$configName = DomainRepository::resolveConfigNameForHost((string) $host, $baseDir);
$file = $baseDir . '/check_config/' . $configName . '_config.php';
if (!is_file($file)) {
$configName = 'index';
$file = $baseDir . '/check_config/index_config.php';
}
$riskNumber = 68;
$riskEnhanced = 'OFF';
if (is_readable($file)) {
$content = file_get_contents($file);
if (preg_match("/define\s*\(\s*'CLOAK_RISK_NUMBER'\s*,\s*'(\d+)'\s*\)/", $content, $m)) {
$riskNumber = (int) $m[1];
}
if (preg_match("/define\s*\(\s*'CLOAK_RISK_ENHANCED'\s*,\s*'([A-Z]+)'\s*\)/", $content, $m)) {
$riskEnhanced = strtoupper($m[1]);
}
}
return [
'config_name' => $configName,
'risk_number' => $riskNumber,
'risk_enhanced' => $riskEnhanced,
];
}
/**
* 解析 API reason:常见为「风险分 + 因子列表」。
*/
public static function parseApiReason($reason)
{
$reason = trim((string) $reason);
$out = ['risk_level' => null, 'factors' => [], 'raw' => $reason];
if ($reason === '') {
return $out;
}
if (preg_match('/^\d+$/', $reason)) {
$out['risk_level'] = (int) $reason;
return $out;
}
if (preg_match('/风险(?:系数|值|分)?\s*[:]\s*(\d+)/u', $reason, $m)) {
$out['risk_level'] = (int) $m[1];
} elseif (preg_match('/^(\d+)\s*[:|\/\-—]\s*(.*)$/su', $reason, $m)) {
$out['risk_level'] = (int) $m[1];
$reason = trim($m[2]);
} elseif (preg_match('/\b(\d{1,3})\s*\/\s*(\d{1,3})\b/', $reason, $m)) {
$out['risk_level'] = (int) $m[1];
}
$factorText = $reason;
if (preg_match('/(?:理由|因素|规则)[:]\s*(.+)$/su', $reason, $m)) {
$factorText = trim($m[1]);
}
$parts = preg_split('/[;|]+/u', $factorText) ?: [];
foreach ($parts as $part) {
$part = trim($part);
if ($part === '' || preg_match('/^\d+$/', $part)) {
continue;
}
$out['factors'][] = $part;
}
return $out;
}
private static function buildFactorRows(array $factors, array $hdata)
{
$rows = [];
foreach ($factors as $i => $text) {
$text = trim((string) $text);
$mapped = self::mapFactorToFields($text);
$values = [];
foreach ($mapped['fields'] as $path) {
$val = self::getNestedValue($hdata, $path);
if ($val !== null) {
$values[$path] = $val;
}
}
$rows[] = [
'index' => $i + 1,
'text' => $text,
'category' => $mapped['category'],
'fields' => $mapped['fields'],
'values' => $values,
];
}
return $rows;
}
private static function collectHighlightedFields(array $factorRows)
{
$map = [];
foreach ($factorRows as $row) {
foreach ($row['values'] as $path => $val) {
$map[$path] = self::formatValue($val);
}
}
return $map;
}
private static function mapFactorToFields($text)
{
$rules = [
['cat' => 'webdriver', 're' => '/WebDriver|webdriver/i', 'fields' => ['webdriver']],
['cat' => 'automation', 're' => '/Phantom|Selenium|window\.chrome|无头|自动化|爬虫|UA/i', 'fields' => ['userAgent', 'automation']],
['cat' => 'canvas', 're' => '/Canvas|canvas/i', 'fields' => ['canvas']],
['cat' => 'webgl', 're' => '/WebGL|renderer|渲染|虚拟|SwiftShader|GPU/i', 'fields' => ['renderer', 'vendor', 'webgl', 'webGpuData', 'webGPU']],
['cat' => 'hardware', 're' => '/硬件|并发|内存|deviceMemory|hardware/i', 'fields' => ['hardwareConcurrency', 'deviceMemory']],
['cat' => 'touch', 're' => '/触摸|touch|仿真器|Emulator/i', 'fields' => ['touchscreen', 'isEmulators', 'platform', 'browser.device.type']],
['cat' => 'screen', 're' => '/分辨率|像素比|视口|availScreen|screen/i', 'fields' => ['screen', 'availScreen', 'devicePixelRatio', 'scale', 'visualViewportOffset']],
['cat' => 'browser', 're' => '/WebRTC|WebGPU|WebGL/i', 'fields' => ['experimental', 'webGPU', 'webGpuData', 'webgl']],
['cat' => 'geo', 're' => '/语言|时区|country|ciso/i', 'fields' => ['languages', 'language', 'ciso', 'timezone', 'timezoneOffset']],
['cat' => 'hostname', 're' => '/DNS|爬虫|Google|Facebook|Tiktok/i', 'fields' => ['ip']],
['cat' => 'fonts', 're' => '/字体|Calibri|Segoe|San Francisco/i', 'fields' => ['fonts', 'browser.os.name']],
];
foreach ($rules as $rule) {
if (preg_match($rule['re'], $text)) {
return ['category' => $rule['cat'], 'fields' => $rule['fields']];
}
}
return ['category' => 'other', 'fields' => ['userAgent', 'platform', 'risk_enhanced']];
}
private static function getNestedValue(array $data, $path)
{
if (strpos($path, '.') === false) {
return array_key_exists($path, $data) ? $data[$path] : null;
}
$cur = $data;
foreach (explode('.', $path) as $seg) {
if (!is_array($cur) || !array_key_exists($seg, $cur)) {
return null;
}
$cur = $cur[$seg];
}
return $cur;
}
private static function formatValue($val)
{
if ($val === null) {
return 'null';
}
if (is_bool($val)) {
return $val ? 'true' : 'false';
}
if (is_array($val)) {
$json = json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json !== false && strlen($json) > 200) {
return substr($json, 0, 200) . '…';
}
return $json ?: '[]';
}
$s = (string) $val;
return strlen($s) > 200 ? substr($s, 0, 200) . '…' : $s;
}
/**
* 根据 reason、hdata、配置阈值与本地复算,生成可操作的诊断结论。
*
* @return array<int,array{level:string,text:string,action:string}>
*/
private static function buildAnalysisConclusions(array $data, array $hdata)
{
$lines = [];
$riskNumber = (int) $data['risk_number'];
$riskLevel = $data['risk_level'];
$localLevel = $data['local_risk_level'];
$apiLevel = $data['api_risk_level'];
$recommended = (int) $data['recommended_risk'];
$configUrl = (string) ($data['config_edit_url'] ?? 'dashboard.php');
$configHint = '可在 <a href="' . htmlspecialchars($configUrl, ENT_QUOTES, 'UTF-8') . '" target="_blank">配置中心</a> 调整风险系数与加强判断';
if ($hdata === [] && trim($data['api_reason']) === '') {
$lines[] = [
'level' => 'warn',
'text' => '本条无指纹 hdata 且无 API reason,二次风控可能未执行、未完成或尚未落库。',
'action' => '若 result 为 wait,请检查 cloakjs / f_check 回调;若长期为空,检查 CLOAK_RISK_NUMBER 是否为 0。',
];
return $lines;
}
if ($riskNumber <= 0) {
if ($hdata !== [] || trim($data['api_reason']) !== '') {
$lines[] = [
'level' => 'warn',
'text' => '配置中二次风控已关闭(风险系数=0),但本条仍出现指纹数据或 reason。',
'action' => 'result/reason 可能来自首次判定或其他规则,不代表 byApiRisk 生效。' . $configHint . '。',
];
} else {
$lines[] = [
'level' => 'ok',
'text' => '二次风控已关闭,不会调用 byApiRisk。',
'action' => $configHint . ' 开启并设置合适分数后可启用。',
];
}
return $lines;
}
if ($data['config_enhanced'] && !$data['hdata_enhanced']) {
$lines[] = [
'level' => 'warn',
'text' => '加强判断已开启,但 hdata 缺少 Canvas/WebGL 等深度字段,API 只能按基础规则计分。',
'action' => '常见于弱网、老旧浏览器或前端采集超时。可暂时关闭加强判断,或排查 cloakjs 加载与采集耗时。',
];
} elseif (!$data['config_enhanced'] && $data['hdata_enhanced']) {
$lines[] = [
'level' => 'ok',
'text' => '配置为基础模式,但 hdata 含完整指纹;本地复算会识别为加强数据。',
'action' => '若需利用 Canvas/WebGL 规则,请在配置中开启加强判断并将风险系数设为 52。',
];
}
if ($riskNumber === 51 && !$data['config_enhanced']) {
$lines[] = [
'level' => 'warn',
'text' => '当前风险系数为 51(旧版默认值),基础模式下易误拦正常移动端访客。',
'action' => '建议改为 68。' . $configHint . '。',
];
} elseif ($riskNumber !== $recommended) {
$delta = $riskNumber - $recommended;
if (!$data['config_enhanced'] && $riskNumber < 65) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('基础模式下风险系数 %d 偏低(建议 %d),拦截偏严,真实用户更易被误拦。', $riskNumber, $recommended),
'action' => '建议提高 3~5 分。' . $configHint . '。',
];
} elseif (!$data['config_enhanced'] && $riskNumber > 75) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('基础模式下风险系数 %d 偏高(建议 %d),自动化/爬虫更易漏过。', $riskNumber, $recommended),
'action' => '建议降低 3~5 分。' . $configHint . '。',
];
} elseif ($data['config_enhanced'] && $riskNumber < 48) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('加强模式下风险系数 %d 偏低(建议 %d),拦截会非常严格。', $riskNumber, $recommended),
'action' => '若误拦过多,可提高到 52 附近。' . $configHint . '。',
];
} elseif ($data['config_enhanced'] && $riskNumber > 55) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('加强模式下风险系数 %d 偏高(建议 %d),高信号自动化可能仍被放行。', $riskNumber, $recommended),
'action' => '建议降低到 52 附近。' . $configHint . '。',
];
} elseif (abs($delta) >= 3) {
$lines[] = [
'level' => 'ok',
'text' => sprintf('当前风险系数 %d,与%s模式建议值 %d 相差 %d 分。', $riskNumber, $data['config_enhanced'] ? '加强' : '基础', $recommended, abs($delta)),
'action' => '若误拦或漏拦明显,可按 ±3~5 微调。' . $configHint . '。',
];
}
}
if ($localLevel !== null && $apiLevel !== null && abs($localLevel - $apiLevel) > 3) {
$lines[] = [
'level' => 'alert',
'text' => sprintf('API reason 解析风险分 %d,本地 risk_analyzer 复算 %d,两者不一致。', $apiLevel, $localLevel),
'action' => '线上 byApiRisk 版本可能与本地 __code__/risk_analyzer_new.php 不同步,或 reason 格式有变,需核对 API 部署。',
];
}
if ($data['expected_blocked'] !== null && $riskLevel !== null) {
$expectedPass = !$data['expected_blocked'];
if ($expectedPass !== $data['actual_pass']) {
$lines[] = [
'level' => 'alert',
'text' => sprintf(
'本地复算:风险 %d %s 阈值 %d → 应%s;但 API 实际返回 %s。',
$riskLevel,
$data['expected_blocked'] ? '>=' : '<',
$riskNumber,
$expectedPass ? '放行' : '拦截',
$data['actual_pass'] ? 'TRUE(放行)' : 'FALSE(拦截)'
),
'action' => '请确认 f_check 传给 API 的 risk_number 与配置一致,且线上 API 判定逻辑未单独覆盖阈值。',
];
} else {
$lines[] = [
'level' => 'ok',
'text' => sprintf('API 结果与本地复算一致:风险 %d,阈值 %d,结论为%s。', $riskLevel, $riskNumber, $data['actual_pass'] ? '放行' : '拦截'),
'action' => '',
];
}
$margin = abs($riskLevel - $riskNumber);
if ($margin <= 5) {
$lines[] = [
'level' => 'warn',
'text' => sprintf('本条为临界分值(距阈值仅 %d 分),小幅调整风险系数即可改变结果。', $margin),
'action' => $data['actual_pass']
? '若认为应拦截,可将风险系数降低 3~5。'
: '若认为应放行,可将风险系数提高 3~5。',
];
}
} elseif ($riskLevel === null && trim($data['api_reason']) !== '') {
$lines[] = [
'level' => 'warn',
'text' => 'API 返回了 reason,但未能解析出有效风险分。',
'action' => '请检查 reason 格式是否变更;下方「API reason」与触发规则可帮助人工判断。',
];
}
if (!empty($data['factors']) && empty($data['parsed_reason']['factors']) && $localLevel === null) {
$lines[] = [
'level' => 'warn',
'text' => 'reason 中未包含触发规则明细,仅有分数或简短文本。',
'action' => '不利于排查误拦原因,建议在 API 侧返回 risk_factors 列表。',
];
}
if (!empty($hdata['webdriver']) && $data['actual_pass']) {
$lines[] = [
'level' => 'alert',
'text' => 'hdata 显示 webdriver=true,但本条仍被放行。',
'action' => sprintf('webdriver 单项约 55 分,当前阈值 %d 可能过高或未命中 API 规则。建议加强模式 + 阈值 52。', $riskNumber),
];
}
$strongHit = false;
$weakOnly = !empty($data['factors']);
foreach ($data['factors'] as $row) {
$cat = $row['category'] ?? 'other';
if (in_array($cat, ['webdriver', 'automation', 'canvas', 'webgl'], true)) {
$strongHit = true;
}
if (!in_array($cat, ['geo', 'hardware'], true)) {
$weakOnly = false;
}
}
if (!$data['actual_pass'] && $weakOnly && !empty($data['factors'])) {
$lines[] = [
'level' => 'warn',
'text' => '本条被拦截,但触发信号以语言/时区/硬件等弱规则为主,无 webdriver/Canvas/WebGL 等强信号。',
'action' => '若大量出现,可能阈值偏低或 IP 国家与浏览器环境不匹配,建议提高风险系数或检查误伤样本。',
];
}
if ($data['actual_pass'] && $strongHit && $riskLevel !== null && $riskLevel >= ($riskNumber - 8)) {
$lines[] = [
'level' => 'warn',
'text' => '存在自动化/Canvas/WebGL 等强信号,风险分接近阈值但仍被放行。',
'action' => '建议降低风险系数 3~5,或确认加强判断已开启。',
];
}
if ($lines === []) {
$lines[] = [
'level' => 'ok',
'text' => '暂无异常,当前配置与 API 返回基本一致。',
'action' => $configHint . '。',
];
}
return $lines;
}
private static function buildSummary(array $data)
{
if (empty($data['local_analysis']) && trim($data['api_reason']) === '') {
return '';
}
$level = $data['risk_level'];
$num = $data['risk_number'];
if ($num <= 0) {
return '二次风控关闭';
}
if ($level === null) {
return '风控 ' . $data['actual_result'];
}
$mode = $data['risk_enhanced'] ? '加强' : '基础';
return sprintf('风险 %d/%d(%s) · %s', $level, $num, $mode, $data['actual_pass'] ? 'TRUE' : 'FALSE');
}
private static function buildHtml(array $data, array $hdata)
{
if ($hdata === [] && trim($data['api_reason']) === '') {
return '';
}
$esc = static function ($s) {
return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
};
$html = '<div class="fp-risk-explain">';
$html .= '<div class="fp-risk-explain-head">';
$html .= '<strong>二次风控判定解析</strong>';
$html .= '<span class="fp-risk-tag">' . $esc($data['actual_result'] === 'true' ? '放行 TRUE' : ($data['actual_result'] === 'false' ? '拦截 FALSE' : $data['actual_result'])) . '</span>';
$html .= '</div>';
if (!empty($data['conclusions'])) {
$html .= '<div class="fp-risk-section-title">分析结论</div>';
$html .= '<ul class="fp-risk-conclusions">';
foreach ($data['conclusions'] as $item) {
$cls = $esc($item['level'] ?? 'ok');
$html .= '<li class="' . $cls . '">';
$html .= $esc($item['text'] ?? '');
if (!empty($item['action'])) {
$html .= '<span class="fp-conc-action">' . $item['action'] . '</span>';
}
$html .= '</li>';
}
$html .= '</ul>';
}
$html .= '<table class="fp-risk-kv"><tbody>';
$html .= '<tr><th>配置</th><td>' . $esc($data['config_name']) . ' · 风险系数 <code>' . (int) $data['risk_number'] . '</code>'
. '(建议 <code>' . (int) $data['recommended_risk'] . '</code> · '
. ($data['config_enhanced'] ? '加强判断 ON' : '加强判断 OFF')
. ($data['hdata_enhanced'] ? ' · hdata 含深度指纹' : '')
. '</td></tr>';
$html .= '<tr><th>风险分</th><td>本地复算 <code>' . ($data['local_risk_level'] !== null ? (int) $data['local_risk_level'] : '—') . '</code>'
. ' · API reason <code>' . ($data['api_risk_level'] !== null ? (int) $data['api_risk_level'] : '—') . '</code></td></tr>';
$html .= '<tr><th>比较</th><td>' . $esc($data['compare_text']) . ' → ' . $esc($data['verdict_text']) . ' · ' . $esc($data['actual_text'] ?? '') . '</td></tr>';
$html .= '<tr><th>API reason</th><td class="fp-risk-reason-raw">' . $esc($data['api_reason'] ?: '—') . '</td></tr>';
if (!empty($data['local_analysis']['risk_category'])) {
$html .= '<tr><th>风险档</th><td>' . $esc($data['local_analysis']['risk_category'] . ' — ' . ($data['local_analysis']['risk_description'] ?? '')) . '</td></tr>';
}
$html .= '</tbody></table>';
if (!empty($data['factors'])) {
$html .= '<div class="fp-risk-section-title">触发规则与依据字段</div><ol class="fp-risk-factor-list">';
foreach ($data['factors'] as $row) {
$html .= '<li><div class="fp-factor-text">' . $esc($row['text']) . '</div>';
if (!empty($row['values'])) {
$html .= '<ul class="fp-factor-fields">';
foreach ($row['values'] as $path => $val) {
$html .= '<li><mark class="fp-field-key">' . $esc($path) . '</mark> = <code>' . $esc(self::formatValue($val)) . '</code></li>';
}
$html .= '</ul>';
} else {
$html .= '<div class="fp-factor-miss text-muted">(未在 hdata 中找到对应字段值,可能为基础模式未采集)</div>';
}
$html .= '</li>';
}
$html .= '</ol>';
}
$html .= '<div class="fp-risk-section-title">完整 hdata(依据字段已高亮)</div>';
$html .= '<pre class="fp-hdata-json">' . self::renderHighlightedHdata($hdata, $data['highlight_fields']) . '</pre>';
$html .= '</div>';
return $html;
}
private static function renderHighlightedHdata(array $hdata, array $highlightFields)
{
$lines = [];
$json = json_encode($hdata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
return '';
}
$hlKeys = array_flip($highlightFields);
foreach (explode("\n", $json) as $line) {
$escaped = htmlspecialchars($line, ENT_QUOTES, 'UTF-8');
if (preg_match('/^\s*"([^"]+)"\s*:/', $line, $m)) {
$key = $m[1];
if (isset($hlKeys[$key])) {
$escaped = '<span class="fp-hl-line">' . $escaped . '</span>';
}
}
$lines[] = $escaped;
}
return implode("\n", $lines);
}
}
+63 -2
View File
@@ -26,7 +26,8 @@ class VisitorLogAnalytics
$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);
$fpRisk = self::fetchFpRiskStats($conn, $where);
$conclusions = self::buildConclusions($summary, $byReason, $byCountry, $byHour, $byDevice, $timing, $compare, $byResult, $fpRisk);
return [
'summary' => $summary,
@@ -37,6 +38,7 @@ class VisitorLogAnalytics
'by_device' => $byDevice,
'timing' => $timing,
'compare' => $compare,
'fp_risk' => $fpRisk,
'conclusions' => $conclusions,
];
}
@@ -338,6 +340,33 @@ class VisitorLogAnalytics
];
}
/**
* 二次风控(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
@@ -347,9 +376,10 @@ class VisitorLogAnalytics
* @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)
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'];
@@ -429,6 +459,37 @@ class VisitorLogAnalytics
$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']);
+1
View File
@@ -23,6 +23,7 @@ class VisitorLogSchema
'http_referer' => "ALTER TABLE `visitor_logs` ADD COLUMN `http_referer` TEXT NULL COMMENT 'HTTP Referer 完整值' AFTER `user_agent`",
'accept_language_raw' => "ALTER TABLE `visitor_logs` ADD COLUMN `accept_language_raw` TEXT NULL COMMENT 'Accept-Language 完整值' AFTER `http_referer`",
'judge_timing' => "ALTER TABLE `visitor_logs` ADD COLUMN `judge_timing` TEXT NULL COMMENT '判定用时 JSON' AFTER `accept_language_raw`",
'fp_hdata' => "ALTER TABLE `visitor_logs` ADD COLUMN `fp_hdata` TEXT NULL COMMENT '指纹 hdata 解码 JSON' AFTER `judge_timing`",
];
foreach ($columns as $name => $ddl) {
if (!self::columnExists($conn, 'visitor_logs', $name)) {