= $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 __DIR__ . '/LogRiskAnalyzer.php'; self::$analyzerLoaded = true; } } private static function runLocalAnalyzer(array $hdata) { if ($hdata === []) { return []; } self::loadAnalyzer(); try { $analyzer = new LogRiskAnalyzer($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'; } if (!class_exists('ConfigLoader', false)) { require_once dirname(__DIR__, 2) . '/config/ConfigLoader.php'; } $configName = ConfigLoader::resolveConfigNameFromUrl((string) $domain, $baseDir); if ($configName === null) { $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 */ 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 = '可在 配置中心 调整风险系数与加强判断'; 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,日志复算风险分 %d,两者不一致。', $apiLevel, $localLevel), 'action' => '线上 byApiRisk 版本可能与 lib/Cloak/LogRiskAnalyzer.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 = '
'; $html .= '
'; $html .= '二次风控判定解析'; $html .= '' . $esc($data['actual_result'] === 'true' ? '放行 TRUE' : ($data['actual_result'] === 'false' ? '拦截 FALSE' : $data['actual_result'])) . ''; $html .= '
'; if (!empty($data['conclusions'])) { $html .= '
分析结论
'; $html .= ''; } $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; if (!empty($data['local_analysis']['risk_category'])) { $html .= ''; } $html .= '
配置' . $esc($data['config_name']) . ' · 风险系数 ' . (int) $data['risk_number'] . '' . '(建议 ' . (int) $data['recommended_risk'] . ') · ' . ($data['config_enhanced'] ? '加强判断 ON' : '加强判断 OFF') . ($data['hdata_enhanced'] ? ' · hdata 含深度指纹' : '') . '
风险分本地复算 ' . ($data['local_risk_level'] !== null ? (int) $data['local_risk_level'] : '—') . '' . ' · API reason ' . ($data['api_risk_level'] !== null ? (int) $data['api_risk_level'] : '—') . '
比较' . $esc($data['compare_text']) . ' → ' . $esc($data['verdict_text']) . ' · ' . $esc($data['actual_text'] ?? '') . '
API reason' . $esc($data['api_reason'] ?: '—') . '
风险档' . $esc($data['local_analysis']['risk_category'] . ' — ' . ($data['local_analysis']['risk_description'] ?? '')) . '
'; if (!empty($data['factors'])) { $html .= '
触发规则与依据字段
    '; foreach ($data['factors'] as $row) { $html .= '
  1. ' . $esc($row['text']) . '
    '; if (!empty($row['values'])) { $html .= '
      '; foreach ($row['values'] as $path => $val) { $html .= '
    • ' . $esc($path) . ' = ' . $esc(self::formatValue($val)) . '
    • '; } $html .= '
    '; } else { $html .= '
    (未在 hdata 中找到对应字段值,可能为基础模式未采集)
    '; } $html .= '
  2. '; } $html .= '
'; } $html .= '
完整 hdata(依据字段已高亮)
'; $html .= '
' . self::renderHighlightedHdata($hdata, $data['highlight_fields']) . '
'; $html .= '
'; 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 = '' . $escaped . ''; } } $lines[] = $escaped; } return implode("\n", $lines); } }