| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- <?php
- namespace App\Http\Controllers\Admin;
- use App\Http\Controllers\Controller;
- use Illuminate\Http\Request;
- use Illuminate\Support\Facades\File;
- /**
- * Web日志行为分析控制器
- */
- class WebLogController extends Controller
- {
- /**
- * 日志列表页面
- */
- public function index(Request $request)
- {
- // 获取可用日期列表
- $availableDates = $this->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);
- }
- }
|