WebLogController.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. <?php
  2. namespace App\Http\Controllers\Admin;
  3. use App\Http\Controllers\Controller;
  4. use Illuminate\Http\Request;
  5. use Illuminate\Support\Facades\File;
  6. /**
  7. * Web日志行为分析控制器
  8. */
  9. class WebLogController extends Controller
  10. {
  11. /**
  12. * 日志列表页面
  13. */
  14. public function index(Request $request)
  15. {
  16. // 获取可用日期列表
  17. $availableDates = $this->getAvailableDates();
  18. // 如果没有指定日期,使用最新的日期
  19. $defaultDate = !empty($availableDates) ? $availableDates[0]['value'] : date('Ymd');
  20. $date = $request->input('date', $defaultDate);
  21. $channel = $request->input('channel', '');
  22. $act = $request->input('act', '');
  23. $md5 = $request->input('md5', '');
  24. $ip = $request->input('ip', '');
  25. $page = $request->input('page', 1);
  26. $pageSize = $request->input('pageSize', 100); // 默认100条
  27. // 读取日志文件
  28. $logFile = storage_path("logs/{$date}_weblog.log");
  29. if (!File::exists($logFile)) {
  30. return view('admin.weblog.index', [
  31. 'logs' => [],
  32. 'total' => 0,
  33. 'date' => $date,
  34. 'channel' => $channel,
  35. 'act' => $act,
  36. 'md5' => $md5,
  37. 'ip' => $ip,
  38. 'page' => $page,
  39. 'pageSize' => $pageSize,
  40. 'availableDates' => $availableDates,
  41. 'availableChannels' => [],
  42. 'stats' => [],
  43. 'error' => "日志文件不存在: {$date}_weblog.log"
  44. ]);
  45. }
  46. // 解析日志
  47. $logs = $this->parseLogFile($logFile, $channel, $act, $md5, $ip);
  48. // 标记相同IP的记录组
  49. $logs = $this->markIpGroups($logs);
  50. // 分页
  51. $total = count($logs);
  52. $logs = array_slice($logs, ($page - 1) * $pageSize, $pageSize);
  53. // 统计数据
  54. $stats = $this->getStatistics($logFile);
  55. // 获取可用渠道列表(从统计数据中提取)
  56. $availableChannels = array_keys($stats['channels']);
  57. sort($availableChannels);
  58. return view('admin.weblog.index', [
  59. 'logs' => $logs,
  60. 'total' => $total,
  61. 'date' => $date,
  62. 'channel' => $channel,
  63. 'act' => $act,
  64. 'md5' => $md5,
  65. 'ip' => $ip,
  66. 'page' => $page,
  67. 'pageSize' => $pageSize,
  68. 'availableDates' => $availableDates,
  69. 'availableChannels' => $availableChannels,
  70. 'stats' => $stats,
  71. 'error' => null
  72. ]);
  73. }
  74. /**
  75. * 解析日志文件
  76. */
  77. private function parseLogFile($logFile, $filterChannel = '', $filterAct = '', $filterMd5 = '', $filterIp = '')
  78. {
  79. $logs = [];
  80. $content = File::get($logFile);
  81. // 按空行分割日志条目
  82. $entries = preg_split('/\n\n+/', $content);
  83. foreach ($entries as $entry) {
  84. if (empty(trim($entry))) continue;
  85. $lines = explode("\n", $entry);
  86. $logData = [
  87. 'date' => '',
  88. 'ip' => '',
  89. 'agent' => '',
  90. 'locale' => '',
  91. 'referer' => '',
  92. 'channel' => '',
  93. 'act' => '',
  94. 'md5' => '',
  95. 'orderid' => '',
  96. 'remark' => '',
  97. 'browser' => '',
  98. 'v' => '',
  99. 'in_fb' => false,
  100. 'os' => '',
  101. 'device' => ''
  102. ];
  103. foreach ($lines as $line) {
  104. // 解析日期
  105. if (preg_match('/^date:\s*(.+)$/', $line, $matches)) {
  106. $logData['date'] = trim($matches[1]);
  107. }
  108. // 解析IP和Agent
  109. if (preg_match('/^ip:\s*([^\s]+).*agent:(.+?)locale:(.+)$/', $line, $matches)) {
  110. $logData['ip'] = trim($matches[1]);
  111. $logData['agent'] = trim($matches[2]);
  112. $logData['locale'] = trim($matches[3]);
  113. // 分析UserAgent
  114. $analysis = $this->analyzeUserAgent($logData['agent']);
  115. $logData['in_fb'] = $analysis['in_fb'];
  116. $logData['os'] = $analysis['os'];
  117. $logData['device'] = $analysis['device'];
  118. }
  119. // 解析referer
  120. if (preg_match('/^referer:\s*(.+)$/', $line, $matches)) {
  121. $logData['referer'] = trim($matches[1]);
  122. }
  123. // 解析content (JSON)
  124. if (preg_match('/^content:\s*(\{.+\})$/', $line, $matches)) {
  125. $json = json_decode($matches[1], true);
  126. if ($json) {
  127. $logData['channel'] = $this->extractChannel($json['host'] ?? '');
  128. $logData['act'] = $json['act'] ?? '';
  129. $logData['md5'] = $json['md5'] ?? '';
  130. $logData['orderid'] = $json['orderid'] ?? '';
  131. $logData['remark'] = $json['remark'] ?? '';
  132. $logData['browser'] = $json['browser'] ?? '';
  133. $logData['v'] = $json['v'] ?? '';
  134. }
  135. }
  136. }
  137. // 过滤测试人员IP
  138. if (strstr($logData['ip'], '116.86.210.235')) continue;
  139. // 筛选
  140. if ($filterChannel && $logData['channel'] !== $filterChannel) continue;
  141. if ($filterAct && $logData['act'] !== $filterAct) continue;
  142. if ($filterMd5 && $logData['md5'] !== $filterMd5) continue;
  143. if ($filterIp && $logData['ip'] !== $filterIp) continue;
  144. if (!empty($logData['date'])) {
  145. $logs[] = $logData;
  146. }
  147. }
  148. // 按时间正序
  149. usort($logs, function($a, $b) {
  150. return strcmp($a['date'], $b['date']);
  151. });
  152. return $logs;
  153. }
  154. /**
  155. * 提取渠道编号 (host最后的数字部分)
  156. */
  157. private function extractChannel($host)
  158. {
  159. if (preg_match('/_(\d+)$/', $host, $matches)) {
  160. return $matches[1];
  161. }
  162. return $host;
  163. }
  164. /**
  165. * 分析UserAgent
  166. */
  167. private function analyzeUserAgent($agent)
  168. {
  169. $result = [
  170. 'in_fb' => false,
  171. 'os' => 'Unknown',
  172. 'device' => 'Unknown'
  173. ];
  174. // 检测是否在Facebook中
  175. if (stripos($agent, 'FBAN') !== false ||
  176. stripos($agent, 'FBAV') !== false ||
  177. stripos($agent, 'IAB') !== false ||
  178. stripos($agent, 'FACEBOOK') !== false ||
  179. stripos($agent, 'FB_IAB') !== false ||
  180. stripos($agent, 'FB4A') !== false) {
  181. $result['in_fb'] = true;
  182. }
  183. // 检测iOS设备及型号
  184. if (stripos($agent, 'iPhone') !== false) {
  185. $result['os'] = 'iOS';
  186. // 提取iPhone型号
  187. if (preg_match('/iPhone\s*(\d+[,_]\d+)?/i', $agent, $matches)) {
  188. if (!empty($matches[1])) {
  189. $result['device'] = 'iPhone ' . str_replace(['_', ','], ['.', '.'], $matches[1]);
  190. } else {
  191. $result['device'] = 'iPhone';
  192. }
  193. } else {
  194. $result['device'] = 'iPhone';
  195. }
  196. // 提取iOS版本
  197. if (preg_match('/OS\s+(\d+)[._](\d+)(?:[._](\d+))?/i', $agent, $matches)) {
  198. $result['os'] = 'iOS ' . $matches[1] . '.' . $matches[2];
  199. }
  200. } elseif (stripos($agent, 'iPad') !== false) {
  201. $result['os'] = 'iOS';
  202. $result['device'] = 'iPad';
  203. // 提取iOS版本
  204. if (preg_match('/OS\s+(\d+)[._](\d+)(?:[._](\d+))?/i', $agent, $matches)) {
  205. $result['os'] = 'iOS ' . $matches[1] . '.' . $matches[2];
  206. }
  207. }
  208. // 检测Android设备及型号
  209. elseif (stripos($agent, 'Android') !== false) {
  210. // 提取Android版本
  211. if (preg_match('/Android\s+([\d.]+)/i', $agent, $matches)) {
  212. $result['os'] = 'Android ' . $matches[1];
  213. } else {
  214. $result['os'] = 'Android';
  215. }
  216. // 提取设备型号(常见厂商)
  217. $deviceModel = 'Android';
  218. // Samsung
  219. if (preg_match('/SM-([A-Z0-9]+)/i', $agent, $matches)) {
  220. $deviceModel = 'Samsung ' . $matches[1];
  221. }
  222. // Huawei
  223. elseif (preg_match('/(HW-|HUAWEI\s?)([A-Z0-9\-]+)/i', $agent, $matches)) {
  224. $deviceModel = 'Huawei ' . $matches[2];
  225. }
  226. // Xiaomi
  227. elseif (preg_match('/(MI\s+|Redmi\s+|POCO\s+)([A-Z0-9\s]+)/i', $agent, $matches)) {
  228. $deviceModel = trim($matches[1] . $matches[2]);
  229. }
  230. // Oppo
  231. elseif (preg_match('/OPPO\s+([A-Z0-9]+)/i', $agent, $matches)) {
  232. $deviceModel = 'OPPO ' . $matches[1];
  233. }
  234. // Vivo
  235. elseif (preg_match('/vivo\s+([A-Z0-9]+)/i', $agent, $matches)) {
  236. $deviceModel = 'vivo ' . $matches[1];
  237. }
  238. // OnePlus
  239. elseif (preg_match('/ONEPLUS\s+([A-Z0-9]+)/i', $agent, $matches)) {
  240. $deviceModel = 'OnePlus ' . $matches[1];
  241. }
  242. // Google Pixel
  243. elseif (preg_match('/Pixel\s+([A-Z0-9\s]+)/i', $agent, $matches)) {
  244. $deviceModel = 'Pixel ' . trim($matches[1]);
  245. }
  246. // 其他通用匹配 - 尝试提取Build/之前的型号
  247. elseif (preg_match('/;\s*([A-Z0-9\-\s]+)\s+Build\//i', $agent, $matches)) {
  248. $model = trim($matches[1]);
  249. // 过滤掉Android版本号
  250. $model = preg_replace('/Android[\s\d.]+/i', '', $model);
  251. if (strlen($model) > 2 && strlen($model) < 30) {
  252. $deviceModel = trim($model);
  253. }
  254. }
  255. $result['device'] = $deviceModel;
  256. }
  257. // Windows
  258. elseif (stripos($agent, 'Windows') !== false) {
  259. $result['device'] = 'PC';
  260. // 提取Windows版本
  261. if (preg_match('/Windows NT\s+([\d.]+)/i', $agent, $matches)) {
  262. $winVersion = $matches[1];
  263. $versionMap = [
  264. '10.0' => 'Windows 10/11',
  265. '6.3' => 'Windows 8.1',
  266. '6.2' => 'Windows 8',
  267. '6.1' => 'Windows 7',
  268. '6.0' => 'Windows Vista',
  269. ];
  270. $result['os'] = $versionMap[$winVersion] ?? 'Windows NT ' . $winVersion;
  271. } else {
  272. $result['os'] = 'Windows';
  273. }
  274. }
  275. // macOS
  276. elseif (stripos($agent, 'Mac OS') !== false || stripos($agent, 'Macintosh') !== false) {
  277. $result['device'] = 'Mac';
  278. // 提取macOS版本
  279. if (preg_match('/Mac OS X\s+([\d_]+)/i', $agent, $matches)) {
  280. $version = str_replace('_', '.', $matches[1]);
  281. $result['os'] = 'macOS ' . $version;
  282. } else {
  283. $result['os'] = 'macOS';
  284. }
  285. }
  286. // Linux
  287. elseif (stripos($agent, 'Linux') !== false) {
  288. $result['os'] = 'Linux';
  289. $result['device'] = 'PC';
  290. }
  291. return $result;
  292. }
  293. /**
  294. * 标记相同IP的记录组
  295. * 先将日志按IP和时间窗口(5分钟)分组,然后重新排序
  296. * 让相同IP在时间相近的记录集中在一起显示
  297. */
  298. private function markIpGroups($logs)
  299. {
  300. if (empty($logs)) {
  301. return $logs;
  302. }
  303. // 第一步:将日志按IP和时间窗口分组(使用滑动窗口)
  304. $ipTimeGroups = [];
  305. $logAssigned = []; // 记录每条日志是否已被分配
  306. foreach ($logs as $index => $log) {
  307. if (isset($logAssigned[$index])) {
  308. continue; // 已经被分配到组中,跳过
  309. }
  310. $ip = $log['ip'];
  311. $time = strtotime($log['date']);
  312. // 创建新组
  313. $newGroup = [
  314. 'ip' => $ip,
  315. 'min_time' => $time,
  316. 'max_time' => $time,
  317. 'logs' => [$log]
  318. ];
  319. $logAssigned[$index] = true;
  320. // 查找其他相同IP且时间在5分钟内的记录
  321. foreach ($logs as $idx => $otherLog) {
  322. if (isset($logAssigned[$idx]) || $idx === $index) {
  323. continue;
  324. }
  325. if ($otherLog['ip'] === $ip) {
  326. $otherTime = strtotime($otherLog['date']);
  327. // 检查是否在时间窗口内(与组内最小或最大时间相差5分钟内)
  328. if (abs($otherTime - $newGroup['min_time']) <= 300 ||
  329. abs($otherTime - $newGroup['max_time']) <= 300) {
  330. $newGroup['logs'][] = $otherLog;
  331. $newGroup['min_time'] = min($newGroup['min_time'], $otherTime);
  332. $newGroup['max_time'] = max($newGroup['max_time'], $otherTime);
  333. $logAssigned[$idx] = true;
  334. }
  335. }
  336. }
  337. $ipTimeGroups[] = $newGroup;
  338. }
  339. // 第二步:按组的最早时间排序
  340. usort($ipTimeGroups, function($a, $b) {
  341. return $a['min_time'] - $b['min_time'];
  342. });
  343. // 第三步:重组日志,在每个组内按时间排序,并标记分隔线
  344. $sortedLogs = [];
  345. foreach ($ipTimeGroups as $index => $group) {
  346. // 组内按时间排序
  347. usort($group['logs'], function($a, $b) {
  348. return strcmp($a['date'], $b['date']);
  349. });
  350. // 添加组内的所有日志
  351. foreach ($group['logs'] as $logIndex => $log) {
  352. // 第一条记录且不是第一组时,添加分隔标记
  353. if ($logIndex === 0 && $index > 0) {
  354. $log['show_separator'] = true;
  355. } else {
  356. $log['show_separator'] = false;
  357. }
  358. $sortedLogs[] = $log;
  359. }
  360. }
  361. return $sortedLogs;
  362. }
  363. /**
  364. * 获取统计数据
  365. */
  366. private function getStatistics($logFile)
  367. {
  368. $logs = $this->parseLogFile($logFile);
  369. $stats = [
  370. 'total' => count($logs),
  371. 'channels' => [],
  372. 'actions' => [],
  373. 'in_fb_count' => 0,
  374. 'os_distribution' => [],
  375. 'unique_users_in_fb' => [],
  376. 'unique_users_not_in_fb' => []
  377. ];
  378. foreach ($logs as $log) {
  379. // 渠道统计
  380. if (!isset($stats['channels'][$log['channel']])) {
  381. $stats['channels'][$log['channel']] = 0;
  382. }
  383. $stats['channels'][$log['channel']]++;
  384. // 行为统计
  385. if (!isset($stats['actions'][$log['act']])) {
  386. $stats['actions'][$log['act']] = 0;
  387. }
  388. $stats['actions'][$log['act']]++;
  389. // FB统计
  390. if ($log['in_fb']) {
  391. $stats['in_fb_count']++;
  392. }
  393. // OS统计
  394. if (!isset($stats['os_distribution'][$log['os']])) {
  395. $stats['os_distribution'][$log['os']] = 0;
  396. }
  397. $stats['os_distribution'][$log['os']]++;
  398. // 唯一用户(区分FB内外)
  399. if (!empty($log['md5'])) {
  400. if ($log['in_fb']) {
  401. $stats['unique_users_in_fb'][$log['md5']] = true;
  402. } else {
  403. $stats['unique_users_not_in_fb'][$log['md5']] = true;
  404. }
  405. }
  406. }
  407. // 排序
  408. arsort($stats['channels']);
  409. arsort($stats['actions']);
  410. arsort($stats['os_distribution']);
  411. $stats['unique_user_count_in_fb'] = count($stats['unique_users_in_fb']);
  412. $stats['unique_user_count_not_in_fb'] = count($stats['unique_users_not_in_fb']);
  413. $stats['unique_user_count'] = $stats['unique_user_count_in_fb'] + $stats['unique_user_count_not_in_fb'];
  414. unset($stats['unique_users_in_fb']);
  415. unset($stats['unique_users_not_in_fb']);
  416. return $stats;
  417. }
  418. /**
  419. * 获取可用日期列表
  420. */
  421. private function getAvailableDates()
  422. {
  423. $logPath = storage_path('logs');
  424. $files = File::glob($logPath . '/*_weblog.log');
  425. $dates = [];
  426. foreach ($files as $file) {
  427. $filename = basename($file);
  428. if (preg_match('/^(\d{8})_weblog\.log$/', $filename, $matches)) {
  429. $dates[] = [
  430. 'value' => $matches[1],
  431. 'label' => date('Y-m-d', strtotime($matches[1]))
  432. ];
  433. }
  434. }
  435. // 按日期倒序
  436. usort($dates, function($a, $b) {
  437. return strcmp($b['value'], $a['value']);
  438. });
  439. return $dates;
  440. }
  441. /**
  442. * 导出CSV
  443. */
  444. public function export(Request $request)
  445. {
  446. $date = $request->input('date', date('Ymd'));
  447. $channel = $request->input('channel', '');
  448. $act = $request->input('act', '');
  449. $md5 = $request->input('md5', '');
  450. $ip = $request->input('ip', '');
  451. $logFile = storage_path("logs/{$date}_weblog.log");
  452. if (!File::exists($logFile)) {
  453. return redirect()->back()->with('error', '日志文件不存在');
  454. }
  455. $logs = $this->parseLogFile($logFile, $channel, $act, $md5, $ip);
  456. $filename = "weblog_{$date}.csv";
  457. $headers = [
  458. 'Content-Type' => 'text/csv; charset=UTF-8',
  459. 'Content-Disposition' => "attachment; filename=\"{$filename}\"",
  460. ];
  461. $callback = function() use ($logs) {
  462. $file = fopen('php://output', 'w');
  463. // UTF-8 BOM
  464. fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
  465. // CSV表头
  466. fputcsv($file, [
  467. '日期时间',
  468. '渠道',
  469. '行为',
  470. '用户ID(MD5)',
  471. 'IP',
  472. '是否FB',
  473. '操作系统',
  474. '设备',
  475. '浏览器',
  476. 'OrderID',
  477. '备注'
  478. ]);
  479. // CSV内容
  480. foreach ($logs as $log) {
  481. fputcsv($file, [
  482. $log['date'],
  483. $log['channel'],
  484. $log['act'],
  485. $log['md5'],
  486. $log['ip'],
  487. $log['in_fb'] ? 'Y' : 'N',
  488. $log['os'],
  489. $log['device'],
  490. $log['browser'],
  491. $log['orderid'],
  492. $log['remark']
  493. ]);
  494. }
  495. fclose($file);
  496. };
  497. return response()->stream($callback, 200, $headers);
  498. }
  499. }