getAvailableDates(); // 如果没有指定日期,使用最新的日期 $defaultDate = !empty($availableDates) ? $availableDates[0]['value'] : date('Ymd'); $date = $request->input('date', $defaultDate); $channel = $request->input('channel', ''); $act = $request->input('act', ''); $md5 = $request->input('md5', ''); $ip = $request->input('ip', ''); $page = $request->input('page', 1); $pageSize = $request->input('pageSize', 100); // 默认100条 // 读取日志文件 $logFile = storage_path("logs/{$date}_weblog.log"); if (!File::exists($logFile)) { return view('admin.weblog.index', [ 'logs' => [], 'total' => 0, 'date' => $date, 'channel' => $channel, 'act' => $act, 'md5' => $md5, 'ip' => $ip, 'page' => $page, 'pageSize' => $pageSize, 'availableDates' => $availableDates, 'availableChannels' => [], 'stats' => [], 'error' => "日志文件不存在: {$date}_weblog.log" ]); } // 解析日志 $logs = $this->parseLogFile($logFile, $channel, $act, $md5, $ip); // 标记相同IP的记录组 $logs = $this->markIpGroups($logs); // 分页 $total = count($logs); $logs = array_slice($logs, ($page - 1) * $pageSize, $pageSize); // 统计数据 $stats = $this->getStatistics($logFile); // 获取可用渠道列表(从统计数据中提取) $availableChannels = array_keys($stats['channels']); sort($availableChannels); return view('admin.weblog.index', [ 'logs' => $logs, 'total' => $total, 'date' => $date, 'channel' => $channel, 'act' => $act, 'md5' => $md5, 'ip' => $ip, 'page' => $page, 'pageSize' => $pageSize, 'availableDates' => $availableDates, 'availableChannels' => $availableChannels, 'stats' => $stats, 'error' => null ]); } /** * 解析日志文件 */ private function parseLogFile($logFile, $filterChannel = '', $filterAct = '', $filterMd5 = '', $filterIp = '') { $logs = []; $content = File::get($logFile); // 按空行分割日志条目 $entries = preg_split('/\n\n+/', $content); foreach ($entries as $entry) { if (empty(trim($entry))) continue; $lines = explode("\n", $entry); $logData = [ 'date' => '', 'ip' => '', 'agent' => '', 'locale' => '', 'referer' => '', 'channel' => '', 'act' => '', 'md5' => '', 'orderid' => '', 'remark' => '', 'browser' => '', 'v' => '', 'in_fb' => false, 'os' => '', 'device' => '' ]; foreach ($lines as $line) { // 解析日期 if (preg_match('/^date:\s*(.+)$/', $line, $matches)) { $logData['date'] = trim($matches[1]); } // 解析IP和Agent if (preg_match('/^ip:\s*([^\s]+).*agent:(.+?)locale:(.+)$/', $line, $matches)) { $logData['ip'] = trim($matches[1]); $logData['agent'] = trim($matches[2]); $logData['locale'] = trim($matches[3]); // 分析UserAgent $analysis = $this->analyzeUserAgent($logData['agent']); $logData['in_fb'] = $analysis['in_fb']; $logData['os'] = $analysis['os']; $logData['device'] = $analysis['device']; } // 解析referer if (preg_match('/^referer:\s*(.+)$/', $line, $matches)) { $logData['referer'] = trim($matches[1]); } // 解析content (JSON) if (preg_match('/^content:\s*(\{.+\})$/', $line, $matches)) { $json = json_decode($matches[1], true); if ($json) { $logData['channel'] = $this->extractChannel($json['host'] ?? ''); $logData['act'] = $json['act'] ?? ''; $logData['md5'] = $json['md5'] ?? ''; $logData['orderid'] = $json['orderid'] ?? ''; $logData['remark'] = $json['remark'] ?? ''; $logData['browser'] = $json['browser'] ?? ''; $logData['v'] = $json['v'] ?? ''; } } } // 过滤测试人员IP if (strstr($logData['ip'], '116.86.210.235')) continue; // 筛选 if ($filterChannel && $logData['channel'] !== $filterChannel) continue; if ($filterAct && $logData['act'] !== $filterAct) continue; if ($filterMd5 && $logData['md5'] !== $filterMd5) continue; if ($filterIp && $logData['ip'] !== $filterIp) continue; if (!empty($logData['date'])) { $logs[] = $logData; } } // 按时间正序 usort($logs, function($a, $b) { return strcmp($a['date'], $b['date']); }); return $logs; } /** * 提取渠道编号 (host最后的数字部分) */ private function extractChannel($host) { if (preg_match('/_(\d+)$/', $host, $matches)) { return $matches[1]; } return $host; } /** * 分析UserAgent */ private function analyzeUserAgent($agent) { $result = [ 'in_fb' => false, 'os' => 'Unknown', 'device' => 'Unknown' ]; // 检测是否在Facebook中 if (stripos($agent, 'FBAN') !== false || stripos($agent, 'FBAV') !== false || stripos($agent, 'IAB') !== false || stripos($agent, 'FACEBOOK') !== false || stripos($agent, 'FB_IAB') !== false || stripos($agent, 'FB4A') !== false) { $result['in_fb'] = true; } // 检测iOS设备及型号 if (stripos($agent, 'iPhone') !== false) { $result['os'] = 'iOS'; // 提取iPhone型号 if (preg_match('/iPhone\s*(\d+[,_]\d+)?/i', $agent, $matches)) { if (!empty($matches[1])) { $result['device'] = 'iPhone ' . str_replace(['_', ','], ['.', '.'], $matches[1]); } else { $result['device'] = 'iPhone'; } } else { $result['device'] = 'iPhone'; } // 提取iOS版本 if (preg_match('/OS\s+(\d+)[._](\d+)(?:[._](\d+))?/i', $agent, $matches)) { $result['os'] = 'iOS ' . $matches[1] . '.' . $matches[2]; } } elseif (stripos($agent, 'iPad') !== false) { $result['os'] = 'iOS'; $result['device'] = 'iPad'; // 提取iOS版本 if (preg_match('/OS\s+(\d+)[._](\d+)(?:[._](\d+))?/i', $agent, $matches)) { $result['os'] = 'iOS ' . $matches[1] . '.' . $matches[2]; } } // 检测Android设备及型号 elseif (stripos($agent, 'Android') !== false) { // 提取Android版本 if (preg_match('/Android\s+([\d.]+)/i', $agent, $matches)) { $result['os'] = 'Android ' . $matches[1]; } else { $result['os'] = 'Android'; } // 提取设备型号(常见厂商) $deviceModel = 'Android'; // Samsung if (preg_match('/SM-([A-Z0-9]+)/i', $agent, $matches)) { $deviceModel = 'Samsung ' . $matches[1]; } // Huawei elseif (preg_match('/(HW-|HUAWEI\s?)([A-Z0-9\-]+)/i', $agent, $matches)) { $deviceModel = 'Huawei ' . $matches[2]; } // Xiaomi elseif (preg_match('/(MI\s+|Redmi\s+|POCO\s+)([A-Z0-9\s]+)/i', $agent, $matches)) { $deviceModel = trim($matches[1] . $matches[2]); } // Oppo elseif (preg_match('/OPPO\s+([A-Z0-9]+)/i', $agent, $matches)) { $deviceModel = 'OPPO ' . $matches[1]; } // Vivo elseif (preg_match('/vivo\s+([A-Z0-9]+)/i', $agent, $matches)) { $deviceModel = 'vivo ' . $matches[1]; } // OnePlus elseif (preg_match('/ONEPLUS\s+([A-Z0-9]+)/i', $agent, $matches)) { $deviceModel = 'OnePlus ' . $matches[1]; } // Google Pixel elseif (preg_match('/Pixel\s+([A-Z0-9\s]+)/i', $agent, $matches)) { $deviceModel = 'Pixel ' . trim($matches[1]); } // 其他通用匹配 - 尝试提取Build/之前的型号 elseif (preg_match('/;\s*([A-Z0-9\-\s]+)\s+Build\//i', $agent, $matches)) { $model = trim($matches[1]); // 过滤掉Android版本号 $model = preg_replace('/Android[\s\d.]+/i', '', $model); if (strlen($model) > 2 && strlen($model) < 30) { $deviceModel = trim($model); } } $result['device'] = $deviceModel; } // Windows elseif (stripos($agent, 'Windows') !== false) { $result['device'] = 'PC'; // 提取Windows版本 if (preg_match('/Windows NT\s+([\d.]+)/i', $agent, $matches)) { $winVersion = $matches[1]; $versionMap = [ '10.0' => 'Windows 10/11', '6.3' => 'Windows 8.1', '6.2' => 'Windows 8', '6.1' => 'Windows 7', '6.0' => 'Windows Vista', ]; $result['os'] = $versionMap[$winVersion] ?? 'Windows NT ' . $winVersion; } else { $result['os'] = 'Windows'; } } // macOS elseif (stripos($agent, 'Mac OS') !== false || stripos($agent, 'Macintosh') !== false) { $result['device'] = 'Mac'; // 提取macOS版本 if (preg_match('/Mac OS X\s+([\d_]+)/i', $agent, $matches)) { $version = str_replace('_', '.', $matches[1]); $result['os'] = 'macOS ' . $version; } else { $result['os'] = 'macOS'; } } // Linux elseif (stripos($agent, 'Linux') !== false) { $result['os'] = 'Linux'; $result['device'] = 'PC'; } return $result; } /** * 标记相同IP的记录组 * 先将日志按IP和时间窗口(5分钟)分组,然后重新排序 * 让相同IP在时间相近的记录集中在一起显示 */ private function markIpGroups($logs) { if (empty($logs)) { return $logs; } // 第一步:将日志按IP和时间窗口分组(使用滑动窗口) $ipTimeGroups = []; $logAssigned = []; // 记录每条日志是否已被分配 foreach ($logs as $index => $log) { if (isset($logAssigned[$index])) { continue; // 已经被分配到组中,跳过 } $ip = $log['ip']; $time = strtotime($log['date']); // 创建新组 $newGroup = [ 'ip' => $ip, 'min_time' => $time, 'max_time' => $time, 'logs' => [$log] ]; $logAssigned[$index] = true; // 查找其他相同IP且时间在5分钟内的记录 foreach ($logs as $idx => $otherLog) { if (isset($logAssigned[$idx]) || $idx === $index) { continue; } if ($otherLog['ip'] === $ip) { $otherTime = strtotime($otherLog['date']); // 检查是否在时间窗口内(与组内最小或最大时间相差5分钟内) if (abs($otherTime - $newGroup['min_time']) <= 300 || abs($otherTime - $newGroup['max_time']) <= 300) { $newGroup['logs'][] = $otherLog; $newGroup['min_time'] = min($newGroup['min_time'], $otherTime); $newGroup['max_time'] = max($newGroup['max_time'], $otherTime); $logAssigned[$idx] = true; } } } $ipTimeGroups[] = $newGroup; } // 第二步:按组的最早时间排序 usort($ipTimeGroups, function($a, $b) { return $a['min_time'] - $b['min_time']; }); // 第三步:重组日志,在每个组内按时间排序,并标记分隔线 $sortedLogs = []; foreach ($ipTimeGroups as $index => $group) { // 组内按时间排序 usort($group['logs'], function($a, $b) { return strcmp($a['date'], $b['date']); }); // 添加组内的所有日志 foreach ($group['logs'] as $logIndex => $log) { // 第一条记录且不是第一组时,添加分隔标记 if ($logIndex === 0 && $index > 0) { $log['show_separator'] = true; } else { $log['show_separator'] = false; } $sortedLogs[] = $log; } } return $sortedLogs; } /** * 获取统计数据 */ private function getStatistics($logFile) { $logs = $this->parseLogFile($logFile); $stats = [ 'total' => count($logs), 'channels' => [], 'actions' => [], 'in_fb_count' => 0, 'os_distribution' => [], 'unique_users_in_fb' => [], 'unique_users_not_in_fb' => [] ]; foreach ($logs as $log) { // 渠道统计 if (!isset($stats['channels'][$log['channel']])) { $stats['channels'][$log['channel']] = 0; } $stats['channels'][$log['channel']]++; // 行为统计 if (!isset($stats['actions'][$log['act']])) { $stats['actions'][$log['act']] = 0; } $stats['actions'][$log['act']]++; // FB统计 if ($log['in_fb']) { $stats['in_fb_count']++; } // OS统计 if (!isset($stats['os_distribution'][$log['os']])) { $stats['os_distribution'][$log['os']] = 0; } $stats['os_distribution'][$log['os']]++; // 唯一用户(区分FB内外) if (!empty($log['md5'])) { if ($log['in_fb']) { $stats['unique_users_in_fb'][$log['md5']] = true; } else { $stats['unique_users_not_in_fb'][$log['md5']] = true; } } } // 排序 arsort($stats['channels']); arsort($stats['actions']); arsort($stats['os_distribution']); $stats['unique_user_count_in_fb'] = count($stats['unique_users_in_fb']); $stats['unique_user_count_not_in_fb'] = count($stats['unique_users_not_in_fb']); $stats['unique_user_count'] = $stats['unique_user_count_in_fb'] + $stats['unique_user_count_not_in_fb']; unset($stats['unique_users_in_fb']); unset($stats['unique_users_not_in_fb']); return $stats; } /** * 获取可用日期列表 */ private function getAvailableDates() { $logPath = storage_path('logs'); $files = File::glob($logPath . '/*_weblog.log'); $dates = []; foreach ($files as $file) { $filename = basename($file); if (preg_match('/^(\d{8})_weblog\.log$/', $filename, $matches)) { $dates[] = [ 'value' => $matches[1], 'label' => date('Y-m-d', strtotime($matches[1])) ]; } } // 按日期倒序 usort($dates, function($a, $b) { return strcmp($b['value'], $a['value']); }); return $dates; } /** * 导出CSV */ public function export(Request $request) { $date = $request->input('date', date('Ymd')); $channel = $request->input('channel', ''); $act = $request->input('act', ''); $md5 = $request->input('md5', ''); $ip = $request->input('ip', ''); $logFile = storage_path("logs/{$date}_weblog.log"); if (!File::exists($logFile)) { return redirect()->back()->with('error', '日志文件不存在'); } $logs = $this->parseLogFile($logFile, $channel, $act, $md5, $ip); $filename = "weblog_{$date}.csv"; $headers = [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => "attachment; filename=\"{$filename}\"", ]; $callback = function() use ($logs) { $file = fopen('php://output', 'w'); // UTF-8 BOM fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); // CSV表头 fputcsv($file, [ '日期时间', '渠道', '行为', '用户ID(MD5)', 'IP', '是否FB', '操作系统', '设备', '浏览器', 'OrderID', '备注' ]); // CSV内容 foreach ($logs as $log) { fputcsv($file, [ $log['date'], $log['channel'], $log['act'], $log['md5'], $log['ip'], $log['in_fb'] ? 'Y' : 'N', $log['os'], $log['device'], $log['browser'], $log['orderid'], $log['remark'] ]); } fclose($file); }; return response()->stream($callback, 200, $headers); } }