diff --git a/.env.example b/.env.example index 713058a..0b89b50 100644 --- a/.env.example +++ b/.env.example @@ -106,3 +106,23 @@ GIT_MONITOR_PROJECTS="service,portal-be,agent-be" # Admin IP whitelist (comma separated, supports wildcard: 192.168.* or 192.168.1.*) TOOLBOX_ADMIN_IPS= + +# Alibaba Cloud SLS Configuration +SLS_ENDPOINT=cn-hangzhou.log.aliyuncs.com +SLS_PROJECT=your-project +# 支持单个或多个 logstore(多个用逗号分隔) +SLS_LOGSTORE=your-logstore +SLS_ACCESS_KEY_ID= +SLS_ACCESS_KEY_SECRET= +SLS_SECURITY_TOKEN= +SLS_QUERY_TIMEOUT=60 + +# AI Service Configuration (OpenAI compatible) - 可在页面上配置多个 AI 服务并切换 +# 以下为默认配置,实际使用时可在 configs 表中配置 log_analysis.ai_providers +AI_ENDPOINT=https://api.openai.com/v1 +AI_API_KEY= +AI_MODEL=gpt-4o +AI_TEMPERATURE=0.3 +AI_TIMEOUT=120 +AI_MAX_TOKENS=4096 + diff --git a/app/Clients/AiClient.php b/app/Clients/AiClient.php new file mode 100644 index 0000000..34dd19d --- /dev/null +++ b/app/Clients/AiClient.php @@ -0,0 +1,275 @@ +configService = $configService; + } + + /** + * 获取所有配置的 AI 提供商 + */ + public function getProviders(): array + { + $providers = $this->configService->get('log_analysis.ai_providers', []); + + // 如果数据库没有配置,使用 .env 默认配置 + if (empty($providers)) { + $envConfig = config('services.ai'); + if (!empty($envConfig['api_key'])) { + $providers = [ + 'default' => [ + 'name' => '默认 (环境变量)', + 'endpoint' => $envConfig['endpoint'], + 'api_key' => $envConfig['api_key'], + 'model' => $envConfig['model'], + 'temperature' => $envConfig['temperature'], + 'timeout' => $envConfig['timeout'], + 'max_tokens' => $envConfig['max_tokens'], + 'enabled' => true, + ], + ]; + } + } + + return $providers; + } + + /** + * 获取当前激活的 AI 提供商 + */ + public function getActiveProvider(): ?array + { + if ($this->currentProvider !== null) { + return $this->currentProvider; + } + + $activeKey = $this->configService->get('log_analysis.active_ai_provider'); + $providers = $this->getProviders(); + + if ($activeKey && isset($providers[$activeKey]) && ($providers[$activeKey]['enabled'] ?? true)) { + $this->currentProvider = $providers[$activeKey]; + $this->currentProvider['key'] = $activeKey; + return $this->currentProvider; + } + + // 返回第一个启用的提供商 + foreach ($providers as $key => $provider) { + if ($provider['enabled'] ?? true) { + $this->currentProvider = $provider; + $this->currentProvider['key'] = $key; + return $this->currentProvider; + } + } + + return null; + } + + /** + * 设置当前使用的提供商 + */ + public function setActiveProvider(string $providerKey): void + { + $providers = $this->getProviders(); + if (!isset($providers[$providerKey])) { + throw new RuntimeException("AI 提供商不存在: {$providerKey}"); + } + + $this->configService->set('log_analysis.active_ai_provider', $providerKey); + $this->currentProvider = $providers[$providerKey]; + $this->currentProvider['key'] = $providerKey; + } + + /** + * 检查是否已配置 + */ + public function isConfigured(): bool + { + return $this->getActiveProvider() !== null; + } + + /** + * 发送聊天请求 (OpenAI 兼容接口) + * + * @param array $messages 消息数组 + * @param string|null $systemPrompt 系统提示词 + * @return string AI 响应内容 + */ + public function chat(array $messages, ?string $systemPrompt = null): string + { + $provider = $this->getActiveProvider(); + if (!$provider) { + throw new RuntimeException('未配置 AI 服务,请在设置页面配置 AI 提供商'); + } + + // 速率限制 + $this->waitForRateLimit($provider['key'] ?? 'default'); + + $allMessages = []; + if ($systemPrompt) { + $allMessages[] = ['role' => 'system', 'content' => $systemPrompt]; + } + $allMessages = array_merge($allMessages, $messages); + + $endpoint = rtrim($provider['endpoint'], '/') . '/chat/completions'; + + $response = Http::timeout($provider['timeout'] ?? 120) + ->withHeaders([ + 'Authorization' => 'Bearer ' . $provider['api_key'], + 'Content-Type' => 'application/json', + ]) + ->post($endpoint, [ + 'model' => $provider['model'], + 'messages' => $allMessages, + 'temperature' => $provider['temperature'] ?? 0.3, + 'max_tokens' => $provider['max_tokens'] ?? 4096, + ]); + + if (!$response->successful()) { + $error = $response->json('error.message') ?? $response->body(); + throw new RuntimeException("AI 请求失败: {$error}"); + } + + return $response->json('choices.0.message.content', ''); + } + + /** + * 分析日志并返回结构化结果 + * + * @param string $logsContent 日志内容 + * @param string|null $codeContext 相关代码上下文 + * @return array 分析结果 + */ + public function analyzeLogs(string $logsContent, ?string $codeContext = null): array + { + $systemPrompt = $this->buildAnalysisSystemPrompt(); + $userContent = $this->buildAnalysisUserPrompt($logsContent, $codeContext); + + $response = $this->chat([ + ['role' => 'user', 'content' => $userContent], + ], $systemPrompt); + + return $this->parseAnalysisResponse($response); + } + + /** + * 测试连接 + */ + public function testConnection(): array + { + $provider = $this->getActiveProvider(); + if (!$provider) { + return [ + 'success' => false, + 'message' => '未配置 AI 服务', + ]; + } + + try { + $response = $this->chat([ + ['role' => 'user', 'content' => 'Hello, please respond with "OK" only.'], + ]); + + return [ + 'success' => true, + 'message' => '连接成功', + 'provider' => $provider['name'] ?? $provider['key'], + 'model' => $provider['model'], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + private function buildAnalysisSystemPrompt(): string + { + return <<<'PROMPT' +你是一个专业的日志分析专家。你的任务是分析提供的日志内容,识别异常和问题,并给出分类和建议。 + +请按照以下 JSON 格式返回分析结果: +{ + "core_anomalies": [ + { + "type": "error|warning|performance|security", + "classification": "database|network|application|configuration|resource|other", + "count": 数量, + "sample": "示例日志内容", + "possible_cause": "可能的原因", + "suggestion": "建议的解决方案" + } + ], + "summary": "整体分析摘要", + "impact": "high|medium|low", + "trends": "趋势分析(可选)" +} + +分类说明: +- type: error(错误), warning(警告), performance(性能问题), security(安全问题) +- classification: database(数据库), network(网络), application(应用逻辑), configuration(配置), resource(资源), other(其他) +- impact: high(高影响), medium(中等影响), low(低影响) + +请确保返回有效的 JSON 格式。 +PROMPT; + } + + private function buildAnalysisUserPrompt(string $logsContent, ?string $codeContext): string + { + $prompt = "请分析以下日志内容:\n\n```\n{$logsContent}\n```"; + + if ($codeContext) { + $prompt .= "\n\n相关代码上下文:\n\n```\n{$codeContext}\n```"; + } + + return $prompt; + } + + private function parseAnalysisResponse(string $response): array + { + // 尝试提取 JSON + if (preg_match('/\{[\s\S]*}/', $response, $matches)) { + $json = $matches[0]; + $decoded = json_decode($json, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + + // 如果无法解析 JSON,返回原始响应 + return [ + 'core_anomalies' => [], + 'summary' => $response, + 'impact' => 'unknown', + 'raw_response' => $response, + ]; + } + + private function waitForRateLimit(string $providerKey): void + { + $key = "ai_rate_limit:{$providerKey}"; + + // 每分钟最多 60 次请求 + $maxAttempts = 60; + $decaySeconds = 60; + + if (RateLimiter::tooManyAttempts($key, $maxAttempts)) { + $seconds = RateLimiter::availableIn($key); + sleep($seconds); + } + + RateLimiter::hit($key, $decaySeconds); + } +} diff --git a/app/Clients/SlsClient.php b/app/Clients/SlsClient.php new file mode 100644 index 0000000..7518efe --- /dev/null +++ b/app/Clients/SlsClient.php @@ -0,0 +1,292 @@ +project = $config['project'] ?? ''; + + // 解析 logstore 配置,支持逗号分隔的多个 logstore + $logstoreConfig = $config['logstore'] ?? ''; + if (!empty($logstoreConfig)) { + // 如果包含逗号,说明是多个 logstore + if (str_contains($logstoreConfig, ',')) { + $this->logstores = array_map('trim', explode(',', $logstoreConfig)); + } else { + $this->logstores = [$logstoreConfig]; + } + } else { + $this->logstores = []; + } + + $this->client = new Aliyun_Log_Client( + $config['endpoint'], + $config['access_key_id'], + $config['access_key_secret'], + $config['security_token'] ?: '' + ); + } + + public function isConfigured(): bool + { + // 只要有 client、project,并且至少有一个 logstore 就算配置完成 + return $this->client !== null + && !empty($this->project) + && !empty($this->logstores); + } + + /** + * 获取配置的所有 logstore + */ + public function getLogstores(): array + { + return $this->logstores; + } + + /** + * 获取默认的 logstore(第一个) + */ + private function getDefaultLogstore(): string + { + if (empty($this->logstores)) { + throw new RuntimeException('没有配置可用的 logstore'); + } + + return $this->logstores[0]; + } + + /** + * 查询日志 + * + * @param int $from 开始时间戳 + * @param int $to 结束时间戳 + * @param string|null $query SLS 查询语句 + * @param int $offset 偏移量 + * @param int $limit 返回数量 + * @param string|null $logstore 可选的 logstore,不传则使用默认 + * @return array{logs: array, count: int, complete: bool} + */ + public function getLogs( + int $from, + int $to, + ?string $query = null, + int $offset = 0, + int $limit = 100, + ?string $logstore = null + ): array { + $this->ensureConfigured(); + + $request = new Aliyun_Log_Models_GetLogsRequest( + $this->project, + $logstore ?? $this->getDefaultLogstore(), + $from, + $to, + '', + $query ?? '*', + $limit, + $offset, + false + ); + + try { + $response = $this->client->getLogs($request); + + $logs = []; + foreach ($response->getLogs() as $log) { + $logs[] = $log->getContents(); + } + + return [ + 'logs' => $logs, + 'count' => $response->getCount(), + 'complete' => $response->isCompleted(), + ]; + } catch (Aliyun_Log_Exception $e) { + throw new RuntimeException( + "SLS 查询失败: [{$e->getErrorCode()}] {$e->getErrorMessage()}", + 0, + $e + ); + } + } + + /** + * 获取日志分布直方图 + * + * @param int $from 开始时间戳 + * @param int $to 结束时间戳 + * @param string|null $query SLS 查询语句 + * @param string|null $logstore 可选的 logstore + * @return array{histograms: array, count: int, complete: bool} + */ + public function getHistograms( + int $from, + int $to, + ?string $query = null, + ?string $logstore = null + ): array { + $this->ensureConfigured(); + + $request = new Aliyun_Log_Models_GetHistogramsRequest( + $this->project, + $logstore ?? $this->getDefaultLogstore(), + $from, + $to, + '', + $query ?? '*' + ); + + try { + $response = $this->client->getHistograms($request); + + $histograms = []; + foreach ($response->getHistograms() as $histogram) { + $histograms[] = [ + 'from' => $histogram->getFrom(), + 'to' => $histogram->getTo(), + 'count' => $histogram->getCount(), + 'complete' => $histogram->isCompleted(), + ]; + } + + return [ + 'histograms' => $histograms, + 'count' => $response->getTotalCount(), + 'complete' => $response->isCompleted(), + ]; + } catch (Aliyun_Log_Exception $e) { + throw new RuntimeException( + "SLS 直方图查询失败: [{$e->getErrorCode()}] {$e->getErrorMessage()}", + 0, + $e + ); + } + } + + /** + * 分页获取所有日志 + * + * @param int $from 开始时间戳 + * @param int $to 结束时间戳 + * @param string|null $query SLS 查询语句 + * @param int $maxLogs 最大返回日志数 + * @param string|null $logstore 可选的 logstore + * @return array + */ + public function getAllLogs( + int $from, + int $to, + ?string $query = null, + int $maxLogs = 1000, + ?string $logstore = null + ): array { + $allLogs = []; + $offset = 0; + $batchSize = 100; + + while (count($allLogs) < $maxLogs) { + $result = $this->getLogs($from, $to, $query, $offset, $batchSize, $logstore); + + $allLogs = array_merge($allLogs, $result['logs']); + + if ($result['complete'] || count($result['logs']) < $batchSize) { + break; + } + + $offset += $batchSize; + } + + return array_slice($allLogs, 0, $maxLogs); + } + + /** + * 测试连接 + */ + public function testConnection(): bool + { + if (!$this->isConfigured()) { + return false; + } + + try { + $now = time(); + $this->getLogs($now - 60, $now, '*', 0, 1); + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * 从多个 logstore 获取日志 + * + * @param int $from 开始时间戳 + * @param int $to 结束时间戳 + * @param string|null $query SLS 查询语句 + * @param int $maxLogs 最大返回日志数 + * @param array|null $logstores 要查询的 logstore 列表,不传则使用配置的所有 logstore + * @return array 按 logstore 分组的日志数据 ['logstore_name' => ['logs' => [...], 'count' => 100]] + */ + public function getAllLogsFromMultipleStores( + int $from, + int $to, + ?string $query = null, + int $maxLogs = 1000, + ?array $logstores = null + ): array { + $this->ensureConfigured(); + + $targetLogstores = $logstores ?? $this->getLogstores(); + + if (empty($targetLogstores)) { + throw new RuntimeException('没有配置可用的 logstore'); + } + + $results = []; + + foreach ($targetLogstores as $logstore) { + try { + $logs = $this->getAllLogs($from, $to, $query, $maxLogs, $logstore); + $results[$logstore] = [ + 'logs' => $logs, + 'count' => count($logs), + 'success' => true, + ]; + } catch (\Exception $e) { + $results[$logstore] = [ + 'logs' => [], + 'count' => 0, + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } + + return $results; + } + + private function ensureConfigured(): void + { + if (!$this->isConfigured()) { + throw new RuntimeException('SLS 客户端未配置,请检查 .env 中的 SLS_* 配置项'); + } + } +} diff --git a/app/Console/Commands/GitMonitorCheckCommand.php b/app/Console/Commands/GitMonitorCheckCommand.php index 8626ca6..ef33f72 100644 --- a/app/Console/Commands/GitMonitorCheckCommand.php +++ b/app/Console/Commands/GitMonitorCheckCommand.php @@ -37,11 +37,31 @@ class GitMonitorCheckCommand extends Command )); if (!empty($result['issues']['develop_merges'])) { - $this->warn(sprintf(' - 检测到 %d 个 develop merge', count($result['issues']['develop_merges']))); + $this->warn(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges']))); + foreach ($result['issues']['develop_merges'] as $commit) { + $this->warn(sprintf( + ' • %s %s (%s)', + substr($commit['hash'], 0, 8), + $commit['subject'], + $commit['author'] + )); + } } if (!empty($result['issues']['missing_functions'])) { - $this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交', count($result['issues']['missing_functions']))); + $this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions']))); + foreach ($result['issues']['missing_functions'] as $issue) { + $this->warn(sprintf( + ' • %s %s (%s)', + substr($issue['commit']['hash'], 0, 8), + $issue['commit']['subject'], + $issue['commit']['author'] + )); + foreach ($issue['details'] as $detail) { + $functions = implode(', ', array_slice($detail['functions'], 0, 5)); + $this->warn(sprintf(' %s => %s', $detail['file'], $functions)); + } + } } } } diff --git a/app/Console/Commands/LogAnalysisCommand.php b/app/Console/Commands/LogAnalysisCommand.php new file mode 100644 index 0000000..8735c98 --- /dev/null +++ b/app/Console/Commands/LogAnalysisCommand.php @@ -0,0 +1,188 @@ +isConfigured()) { + $this->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项'); + return Command::FAILURE; + } + + if (!$aiService->isConfigured()) { + $this->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项'); + return Command::FAILURE; + } + + // 解析时间参数 + $from = $this->parseTime($this->option('from') ?? '-1h'); + $to = $this->parseTime($this->option('to') ?? 'now'); + + if ($from >= $to) { + $this->error('开始时间必须早于结束时间'); + return Command::FAILURE; + } + + // 解析分析模式 + $modeOption = $this->option('mode'); + $mode = $modeOption === 'logs+code' + ? AnalysisMode::LogsWithCode + : AnalysisMode::Logs; + + $query = $this->option('query'); + + $this->info("开始分析日志..."); + $this->line(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}"); + $this->line(" 查询语句: " . ($query ?: '*')); + $this->line(" 分析模式: {$mode->label()}"); + $this->newLine(); + + try { + $result = $analysisService->analyze( + $from, + $to, + $query, + $mode, + !$this->option('no-save') + ); + + // 输出到文件 + if ($outputPath = $this->option('output')) { + $json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + file_put_contents($outputPath, $json); + $this->info("报告已保存到: {$outputPath}"); + } + + // 推送到钉钉 + if ($this->option('push')) { + $this->line("正在推送到钉钉..."); + $pushed = $analysisService->pushToNotification($result); + if ($pushed) { + $this->info("已推送到钉钉"); + } else { + $this->warn("钉钉推送失败"); + } + } + + // 显示摘要 + $this->displaySummary($result); + + return Command::SUCCESS; + } catch (\Exception $e) { + $this->error("分析失败: {$e->getMessage()}"); + return Command::FAILURE; + } + } + + /** + * 解析时间参数 + */ + private function parseTime(string $input): Carbon + { + // 相对时间格式: -1h, -30m, -1d, -2w + if (preg_match('/^-(\d+)([hmsdw])$/', $input, $matches)) { + $value = (int) $matches[1]; + $unit = $matches[2]; + + return match ($unit) { + 'h' => Carbon::now()->subHours($value), + 'm' => Carbon::now()->subMinutes($value), + 's' => Carbon::now()->subSeconds($value), + 'd' => Carbon::now()->subDays($value), + 'w' => Carbon::now()->subWeeks($value), + default => Carbon::now(), + }; + } + + // 特殊值 + if ($input === 'now') { + return Carbon::now(); + } + + // 尝试解析为日期时间 + return Carbon::parse($input); + } + + /** + * 显示分析摘要 + */ + private function displaySummary(array $result): void + { + $this->newLine(); + $this->info('=== 分析摘要 ==='); + $this->line("总日志数: {$result['metadata']['total_logs']}"); + $this->line("分析应用数: {$result['metadata']['apps_analyzed']}"); + $this->line("执行时间: {$result['metadata']['execution_time_ms']}ms"); + $this->newLine(); + + if (empty($result['results'])) { + $this->warn('未找到匹配的日志'); + return; + } + + foreach ($result['results'] as $appName => $appResult) { + $this->line("【{$appName}】"); + + if (isset($appResult['error'])) { + $this->error(" 分析失败: {$appResult['error']}"); + continue; + } + + $impact = $appResult['impact'] ?? 'unknown'; + $impactColor = match ($impact) { + 'high' => 'red', + 'medium' => 'yellow', + 'low' => 'green', + default => 'white', + }; + + $this->line(" 日志数: {$appResult['log_count']}"); + $this->line(" 代码上下文: " . ($appResult['has_code_context'] ? '是' : '否')); + $this->line(" 影响级别: {$impact}"); + $this->line(" 摘要: " . ($appResult['summary'] ?? 'N/A')); + + $anomalies = $appResult['core_anomalies'] ?? []; + if (!empty($anomalies)) { + $this->line(" 异常数: " . count($anomalies)); + + $table = []; + foreach (array_slice($anomalies, 0, 5) as $anomaly) { + $table[] = [ + $anomaly['type'] ?? 'N/A', + $anomaly['classification'] ?? 'N/A', + $anomaly['count'] ?? 1, + mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40), + ]; + } + + $this->table(['类型', '分类', '数量', '可能原因'], $table); + } + + $this->newLine(); + } + } +} diff --git a/app/Enums/AnalysisMode.php b/app/Enums/AnalysisMode.php new file mode 100644 index 0000000..a046f13 --- /dev/null +++ b/app/Enums/AnalysisMode.php @@ -0,0 +1,17 @@ + '仅日志分析', + self::LogsWithCode => '日志 + 代码分析', + }; + } +} diff --git a/app/Http/Controllers/LogAnalysisController.php b/app/Http/Controllers/LogAnalysisController.php new file mode 100644 index 0000000..a88b3a3 --- /dev/null +++ b/app/Http/Controllers/LogAnalysisController.php @@ -0,0 +1,315 @@ +validate([ + 'from' => 'required|date', + 'to' => 'required|date|after:from', + 'query' => 'nullable|string|max:1000', + 'limit' => 'nullable|integer|min:1|max:1000', + 'logstores' => 'nullable|array', + 'logstores.*' => 'string', + ]); + + if (!$this->slsService->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'SLS 服务未配置,请检查 .env 中的 SLS_* 配置项', + ], 400); + } + + try { + // 如果指定了多个 logstore,使用多 logstore 查询 + if (!empty($validated['logstores']) && count($validated['logstores']) > 1) { + $result = $this->analysisService->queryLogsFromMultipleStores( + Carbon::parse($validated['from']), + Carbon::parse($validated['to']), + $validated['query'] ?? null, + $validated['limit'] ?? 100, + $validated['logstores'] + ); + } else { + // 单个 logstore 查询 + $result = $this->analysisService->queryLogs( + Carbon::parse($validated['from']), + Carbon::parse($validated['to']), + $validated['query'] ?? null, + $validated['limit'] ?? 100 + ); + } + + return response()->json([ + 'success' => true, + 'data' => $result, + ]); + } catch (\Exception $e) { + $errorMessage = $e->getMessage(); + + // 优化常见错误提示 + if (str_contains($errorMessage, 'key') && str_contains($errorMessage, 'is not config as key value config')) { + $errorMessage = 'SLS 查询语法错误:使用 "字段:值" 语法需要该字段配置为键值索引。建议使用全文搜索(如 "ERROR")或 SQL 语法(如 "* | where level = \"ERROR\"")'; + } elseif (str_contains($errorMessage, 'ParameterInvalid')) { + $errorMessage = 'SLS 查询参数无效:' . $errorMessage . '。请检查查询语法是否正确'; + } + + return response()->json([ + 'success' => false, + 'message' => $errorMessage, + ], 500); + } + } + + /** + * 获取可用的 logstore 列表 + */ + public function getLogstores(): JsonResponse + { + if (!$this->slsService->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'SLS 服务未配置', + ], 400); + } + + try { + $logstores = $this->slsService->getLogstores(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'logstores' => $logstores, + ], + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * 执行异步分析(创建后台任务) + */ + public function analyze(Request $request): JsonResponse + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date|after:from', + 'query' => 'nullable|string|max:1000', + 'mode' => 'nullable|in:logs,logs+code', + ]); + + if (!$this->slsService->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'SLS 服务未配置', + ], 400); + } + + if (!$this->aiService->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'AI 服务未配置,请在设置页面配置 AI 提供商', + ], 400); + } + + $mode = ($validated['mode'] ?? 'logs') === 'logs+code' + ? AnalysisMode::LogsWithCode + : AnalysisMode::Logs; + + try { + // 创建异步分析任务 + $report = $this->analysisService->analyzeAsync( + Carbon::parse($validated['from']), + Carbon::parse($validated['to']), + $validated['query'] ?? null, + $mode + ); + + return response()->json([ + 'success' => true, + 'message' => '分析任务已创建,请在历史报告中查看结果', + 'data' => [ + 'report_id' => $report->id, + 'status' => $report->status, + ], + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * 获取历史报告列表 + */ + public function listReports(Request $request): JsonResponse + { + $validated = $request->validate([ + 'limit' => 'nullable|integer|min:1|max:100', + 'offset' => 'nullable|integer|min:0', + ]); + + $result = $this->analysisService->getReports( + $validated['limit'] ?? 20, + $validated['offset'] ?? 0 + ); + + return response()->json([ + 'success' => true, + 'data' => $result, + ]); + } + + /** + * 获取单个报告详情 + */ + public function getReport(int $id): JsonResponse + { + $report = $this->analysisService->getReport($id); + + if (!$report) { + return response()->json([ + 'success' => false, + 'message' => '报告不存在', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $report, + ]); + } + + /** + * 获取配置 + */ + public function getConfig(): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => [ + 'sls_configured' => $this->slsService->isConfigured(), + 'ai_configured' => $this->aiService->isConfigured(), + 'ai_providers' => $this->aiService->getProviders(), + 'active_ai_provider' => $this->aiService->getActiveProvider(), + 'app_env_map' => $this->configService->get('log_analysis.app_env_map', []), + 'settings' => $this->configService->get('log_analysis.settings', [ + 'max_logs_per_batch' => 1000, + 'max_logs_per_app' => 500, + 'default_time_window_minutes' => 60, + 'schedule_enabled' => false, + ]), + ], + ]); + } + + /** + * 更新配置(需要管理员权限) + */ + public function updateConfig(Request $request): JsonResponse + { + $validated = $request->validate([ + 'app_env_map' => 'nullable|array', + 'settings' => 'nullable|array', + 'ai_providers' => 'nullable|array', + 'active_ai_provider' => 'nullable|string', + ]); + + try { + if (isset($validated['app_env_map'])) { + $this->configService->set( + 'log_analysis.app_env_map', + $validated['app_env_map'], + 'Log analysis app to project/env mapping' + ); + } + + if (isset($validated['settings'])) { + $this->configService->set( + 'log_analysis.settings', + $validated['settings'], + 'Log analysis settings' + ); + } + + if (isset($validated['ai_providers'])) { + $this->aiService->saveProviders($validated['ai_providers']); + } + + if (isset($validated['active_ai_provider'])) { + $this->aiService->setActiveProvider($validated['active_ai_provider']); + } + + return response()->json([ + 'success' => true, + 'message' => '配置已更新', + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 测试 SLS 连接 + */ + public function testSlsConnection(): JsonResponse + { + if (!$this->slsService->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'SLS 服务未配置', + ]); + } + + $connected = $this->slsService->testConnection(); + + return response()->json([ + 'success' => $connected, + 'message' => $connected ? 'SLS 连接成功' : 'SLS 连接失败', + ]); + } + + /** + * 测试 AI 连接 + */ + public function testAiConnection(): JsonResponse + { + $result = $this->aiService->testConnection(); + + return response()->json([ + 'success' => $result['success'], + 'message' => $result['message'], + 'data' => $result, + ]); + } +} diff --git a/app/Jobs/LogAnalysisJob.php b/app/Jobs/LogAnalysisJob.php new file mode 100644 index 0000000..a391b32 --- /dev/null +++ b/app/Jobs/LogAnalysisJob.php @@ -0,0 +1,224 @@ +reportId); + if (!$report) { + Log::error("LogAnalysisJob: Report not found", ['report_id' => $this->reportId]); + return; + } + + try { + $startTime = microtime(true); + + // 1. 获取日志 + $logs = $slsService->fetchLogs($this->from, $this->to, $this->query); + + if ($logs->isEmpty()) { + $report->update([ + 'status' => 'completed', + 'total_logs' => 0, + 'results' => [], + 'metadata' => [ + 'total_logs' => 0, + 'apps_analyzed' => 0, + 'execution_time_ms' => 0, + 'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'), + 'message' => '未找到匹配的日志', + ], + ]); + return; + } + + // 2. 按 app_name 分组 + $grouped = $slsService->groupByAppName($logs); + + // 3. 分析每个分组 + $results = []; + $settings = $configService->get('log_analysis.settings', []); + $maxLogsPerApp = $settings['max_logs_per_app'] ?? 500; + + foreach ($grouped as $appName => $appLogs) { + $appLogsCollection = collect($appLogs); + + // 限制每个 app 的日志数量 + if ($appLogsCollection->count() > $maxLogsPerApp) { + $appLogsCollection = $appLogsCollection->take($maxLogsPerApp); + } + + // 获取代码上下文(如果需要) + $codeContext = null; + if ($this->mode === AnalysisMode::LogsWithCode) { + $repoPath = $codeContextService->getRepoPath($appName); + if ($repoPath) { + $codeContext = $codeContextService->extractRelevantCode($repoPath, $appLogsCollection); + } + } + + // 准备日志内容 + $logsContent = $this->formatLogsForAnalysis($appLogsCollection); + + // AI 分析 + try { + $results[$appName] = $aiService->analyzeLogs($logsContent, $codeContext); + $results[$appName]['log_count'] = $appLogsCollection->count(); + $results[$appName]['has_code_context'] = $codeContext !== null; + } catch (\Exception $e) { + $results[$appName] = [ + 'error' => $e->getMessage(), + 'log_count' => $appLogsCollection->count(), + 'has_code_context' => false, + ]; + } + } + + $executionTime = (microtime(true) - $startTime) * 1000; + + // 4. 更新报告 + $report->update([ + 'status' => 'completed', + 'total_logs' => $logs->count(), + 'results' => $results, + 'metadata' => [ + 'total_logs' => $logs->count(), + 'apps_analyzed' => count($results), + 'execution_time_ms' => round($executionTime), + 'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'), + ], + ]); + + // 5. 推送通知(如果需要) + if ($this->pushNotification) { + $this->pushToNotification($report, $dingTalkService); + } + + Log::info("LogAnalysisJob: Completed", [ + 'report_id' => $this->reportId, + 'total_logs' => $logs->count(), + 'execution_time_ms' => round($executionTime), + ]); + + } catch (\Exception $e) { + Log::error("LogAnalysisJob: Failed", [ + 'report_id' => $this->reportId, + 'error' => $e->getMessage(), + ]); + + $report->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + ]); + } + } + + private function formatLogsForAnalysis(Collection $logs): string + { + $formatted = []; + + foreach ($logs as $log) { + $line = sprintf( + "[%s] [%s] %s", + $log['time'] ?? 'N/A', + $log['level'] ?? 'N/A', + $log['message'] ?? json_encode($log['_raw'] ?? $log) + ); + + if (!empty($log['trace'])) { + $line .= "\n" . $log['trace']; + } + + $formatted[] = $line; + } + + return implode("\n\n", $formatted); + } + + private function pushToNotification(LogAnalysisReport $report, DingTalkService $dingTalkService): void + { + $lines = []; + $lines[] = "📊 SLS 日志分析报告"; + $lines[] = "时间范围: {$report->from_time->format('Y-m-d H:i:s')} ~ {$report->to_time->format('Y-m-d H:i:s')}"; + $lines[] = "总日志数: {$report->total_logs}"; + $lines[] = ""; + + foreach ($report->results as $appName => $appResult) { + $lines[] = "【{$appName}】"; + + if (isset($appResult['error'])) { + $lines[] = " 分析失败: {$appResult['error']}"; + continue; + } + + $impact = $appResult['impact'] ?? 'unknown'; + $impactEmoji = match ($impact) { + 'high' => '🔴', + 'medium' => '🟡', + 'low' => '🟢', + default => '⚪', + }; + + $lines[] = " 影响级别: {$impactEmoji} {$impact}"; + $lines[] = " 摘要: " . ($appResult['summary'] ?? 'N/A'); + + $anomalies = $appResult['core_anomalies'] ?? []; + if (!empty($anomalies)) { + $lines[] = " 异常数: " . count($anomalies); + foreach (array_slice($anomalies, 0, 3) as $anomaly) { + $lines[] = " - [{$anomaly['classification']}] {$anomaly['possible_cause']}"; + } + } + + $lines[] = ""; + } + + $message = implode("\n", $lines); + + try { + $dingTalkService->sendText($message); + } catch (\Exception $e) { + Log::warning("LogAnalysisJob: Failed to push notification", [ + 'report_id' => $this->reportId, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Models/LogAnalysisReport.php b/app/Models/LogAnalysisReport.php new file mode 100644 index 0000000..d5f9459 --- /dev/null +++ b/app/Models/LogAnalysisReport.php @@ -0,0 +1,25 @@ + 'datetime', + 'to_time' => 'datetime', + 'results' => 'array', + 'metadata' => 'array', + ]; +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 772e511..7a83db0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,19 @@ namespace App\Providers; +use App\Clients\AgentClient; +use App\Clients\AiClient; +use App\Clients\MonoClient; +use App\Clients\SlsClient; +use App\Services\AiService; +use App\Services\CodeContextService; +use App\Services\ConfigService; +use App\Services\DingTalkService; +use App\Services\EnvService; +use App\Services\GitMonitorService; +use App\Services\JiraService; +use App\Services\LogAnalysisService; +use App\Services\SlsService; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,11 +24,22 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // 注册应用服务 - $this->app->singleton(\App\Services\JiraService::class); - $this->app->singleton(\App\Services\ConfigService::class); - $this->app->singleton(\App\Services\DingTalkService::class); - $this->app->singleton(\App\Services\GitMonitorService::class); + // 注册 Clients + $this->app->singleton(AgentClient::class); + $this->app->singleton(MonoClient::class); + $this->app->singleton(SlsClient::class); + $this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class))); + + // 注册 Services + $this->app->singleton(ConfigService::class); + $this->app->singleton(JiraService::class); + $this->app->singleton(DingTalkService::class); + $this->app->singleton(EnvService::class); + $this->app->singleton(GitMonitorService::class); + $this->app->singleton(SlsService::class); + $this->app->singleton(AiService::class); + $this->app->singleton(CodeContextService::class); + $this->app->singleton(LogAnalysisService::class); } /** diff --git a/app/Providers/ClientServiceProvider.php b/app/Providers/ClientServiceProvider.php deleted file mode 100644 index 5a2ce1f..0000000 --- a/app/Providers/ClientServiceProvider.php +++ /dev/null @@ -1,33 +0,0 @@ -app->singleton(AgentClient::class, function () { - return new AgentClient(); - }); - - $this->app->singleton(MonoClient::class, function () { - return new MonoClient(); - }); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - // - } -} - diff --git a/app/Providers/EnvServiceProvider.php b/app/Providers/EnvServiceProvider.php deleted file mode 100644 index 0709757..0000000 --- a/app/Providers/EnvServiceProvider.php +++ /dev/null @@ -1,33 +0,0 @@ -app->singleton(\App\Services\EnvService::class); - } - - /** - * 启动服务 - */ - public function boot(): void - { - // 注册Artisan命令 - if ($this->app->runningInConsole()) { - $this->commands([ - EnvCommand::class, - ]); - } - - - } -} diff --git a/app/Providers/GitMonitorServiceProvider.php b/app/Providers/GitMonitorServiceProvider.php deleted file mode 100644 index c36da0c..0000000 --- a/app/Providers/GitMonitorServiceProvider.php +++ /dev/null @@ -1,25 +0,0 @@ -app->runningInConsole()) { - $this->commands([ - GitMonitorCheckCommand::class, - GitMonitorCacheCommand::class, - ]); - } - } -} diff --git a/app/Services/AiService.php b/app/Services/AiService.php new file mode 100644 index 0000000..760f0d9 --- /dev/null +++ b/app/Services/AiService.php @@ -0,0 +1,126 @@ +client->isConfigured(); + } + + /** + * 获取所有 AI 提供商配置 + */ + public function getProviders(): array + { + return $this->client->getProviders(); + } + + /** + * 获取当前激活的提供商 + */ + public function getActiveProvider(): ?array + { + return $this->client->getActiveProvider(); + } + + /** + * 设置激活的提供商 + */ + public function setActiveProvider(string $providerKey): void + { + $this->client->setActiveProvider($providerKey); + } + + /** + * 保存 AI 提供商配置 + * + * @param array $providers 提供商配置数组 + */ + public function saveProviders(array $providers): void + { + // 验证配置格式 + foreach ($providers as $key => $provider) { + if (empty($provider['endpoint']) || empty($provider['api_key']) || empty($provider['model'])) { + throw new \InvalidArgumentException("提供商 {$key} 配置不完整"); + } + } + + $this->configService->set( + 'log_analysis.ai_providers', + $providers, + 'AI 服务提供商配置' + ); + } + + /** + * 添加或更新单个提供商 + */ + public function saveProvider(string $key, array $config): void + { + $providers = $this->getProviders(); + $providers[$key] = array_merge($providers[$key] ?? [], $config); + $this->saveProviders($providers); + } + + /** + * 删除提供商 + */ + public function deleteProvider(string $key): void + { + $providers = $this->getProviders(); + unset($providers[$key]); + $this->saveProviders($providers); + + // 如果删除的是当前激活的,清除激活状态 + $activeKey = $this->configService->get('log_analysis.active_ai_provider'); + if ($activeKey === $key) { + $this->configService->set('log_analysis.active_ai_provider', null); + } + } + + /** + * 分析日志 + * + * @param string $logsContent 日志内容 + * @param string|null $codeContext 代码上下文 + * @return array 分析结果 + */ + public function analyzeLogs(string $logsContent, ?string $codeContext = null): array + { + return $this->client->analyzeLogs($logsContent, $codeContext); + } + + /** + * 自定义提示词分析 + * + * @param string $content 要分析的内容 + * @param string $prompt 自定义提示词 + * @return string AI 响应 + */ + public function analyze(string $content, string $prompt): string + { + return $this->client->chat([ + ['role' => 'user', 'content' => $prompt . "\n\n" . $content], + ]); + } + + /** + * 测试连接 + */ + public function testConnection(): array + { + return $this->client->testConnection(); + } +} diff --git a/app/Services/CodeContextService.php b/app/Services/CodeContextService.php new file mode 100644 index 0000000..bb9518f --- /dev/null +++ b/app/Services/CodeContextService.php @@ -0,0 +1,267 @@ +configService->get('log_analysis.app_env_map', []); + + if (!isset($appEnvMap[$appName])) { + return null; + } + + $mapping = $appEnvMap[$appName]; + $project = $mapping['project'] ?? null; + $env = $mapping['env'] ?? null; + + if (!$project || !$env) { + return null; + } + + try { + $envContent = $this->envService->getEnvContent($project, $env); + $repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH'); + + if ($repoPath && is_dir($repoPath)) { + return $repoPath; + } + } catch (\Exception $e) { + // 忽略错误,返回 null + } + + return null; + } + + /** + * 从日志中提取相关代码片段 + * + * @param string $repoPath 代码仓库路径 + * @param Collection $logs 日志集合 + * @return string|null 代码上下文 + */ + public function extractRelevantCode(string $repoPath, Collection $logs): ?string + { + $codeSnippets = []; + $processedFiles = []; + + foreach ($logs as $log) { + $file = $log['file'] ?? null; + $line = $log['line'] ?? null; + + if (!$file || !$line) { + // 尝试从 trace 中提取 + $extracted = $this->extractFileLineFromTrace($log['trace'] ?? ''); + if ($extracted) { + $file = $extracted['file']; + $line = $extracted['line']; + } + } + + if (!$file || !$line) { + continue; + } + + // 构建完整路径 + $fullPath = $this->resolveFilePath($repoPath, $file); + if (!$fullPath || !File::exists($fullPath)) { + continue; + } + + // 避免重复处理同一文件的同一位置 + $key = "{$fullPath}:{$line}"; + if (isset($processedFiles[$key])) { + continue; + } + $processedFiles[$key] = true; + + // 提取代码片段 + $snippet = $this->extractCodeSnippet($fullPath, (int) $line); + if ($snippet) { + $codeSnippets[] = $snippet; + } + + // 限制代码片段数量 + if (count($codeSnippets) >= 5) { + break; + } + } + + if (empty($codeSnippets)) { + return null; + } + + return implode("\n\n---\n\n", $codeSnippets); + } + + /** + * 提取指定文件指定行附近的代码 + * + * @param string $filePath 文件路径 + * @param int $line 行号 + * @return string|null + */ + public function extractCodeSnippet(string $filePath, int $line): ?string + { + if (!File::exists($filePath)) { + return null; + } + + $content = File::get($filePath); + $lines = explode("\n", $content); + $totalLines = count($lines); + + $startLine = max(1, $line - $this->contextLines); + $endLine = min($totalLines, $line + $this->contextLines); + + $snippet = []; + $snippet[] = "// File: " . basename($filePath); + $snippet[] = "// Lines: {$startLine}-{$endLine} (target: {$line})"; + $snippet[] = ""; + + for ($i = $startLine - 1; $i < $endLine; $i++) { + $lineNum = $i + 1; + $marker = ($lineNum === $line) ? '>>>' : ' '; + $snippet[] = sprintf("%s %4d | %s", $marker, $lineNum, $lines[$i] ?? ''); + } + + return implode("\n", $snippet); + } + + /** + * 从堆栈跟踪中提取文件和行号 + * + * @param string $trace + * @return array|null ['file' => string, 'line' => int] + */ + private function extractFileLineFromTrace(string $trace): ?array + { + // 匹配常见的堆栈跟踪格式 + // PHP: at /path/to/file.php:123 + // PHP: #0 /path/to/file.php(123): function() + // Java: at com.example.Class.method(File.java:123) + + $patterns = [ + '/at\s+([^\s:]+):(\d+)/', + '/#\d+\s+([^\s(]+)\((\d+)\)/', + '/([^\s:]+\.php):(\d+)/', + '/\(([^:]+):(\d+)\)/', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $trace, $matches)) { + return [ + 'file' => $matches[1], + 'line' => (int) $matches[2], + ]; + } + } + + return null; + } + + /** + * 解析文件路径 + * + * @param string $repoPath 仓库根路径 + * @param string $file 文件路径(可能是相对路径或绝对路径) + * @return string|null + */ + private function resolveFilePath(string $repoPath, string $file): ?string + { + // 如果是绝对路径且存在 + if (str_starts_with($file, '/') && File::exists($file)) { + return $file; + } + + // 尝试作为相对路径 + $fullPath = rtrim($repoPath, '/') . '/' . ltrim($file, '/'); + if (File::exists($fullPath)) { + return $fullPath; + } + + // 尝试在仓库中搜索文件名 + $filename = basename($file); + $found = $this->findFileInRepo($repoPath, $filename); + + return $found; + } + + /** + * 在仓库中搜索文件 + * + * @param string $repoPath + * @param string $filename + * @return string|null + */ + private function findFileInRepo(string $repoPath, string $filename): ?string + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($repoPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getFilename() === $filename) { + return $file->getPathname(); + } + } + + return null; + } + + /** + * 从 .env 内容中解析指定键的值 + * + * @param string $envContent + * @param string $key + * @return string|null + */ + private function parseEnvValue(string $envContent, string $key): ?string + { + $lines = explode("\n", $envContent); + + foreach ($lines as $line) { + $line = trim($line); + + // 跳过注释和空行 + if (empty($line) || str_starts_with($line, '#')) { + continue; + } + + if (str_starts_with($line, "{$key}=")) { + $value = substr($line, strlen($key) + 1); + // 移除引号 + $value = trim($value, '"\''); + return $value ?: null; + } + } + + return null; + } + + /** + * 设置上下文行数 + */ + public function setContextLines(int $lines): void + { + $this->contextLines = $lines; + } +} diff --git a/app/Services/DingTalkService.php b/app/Services/DingTalkService.php index 50605c8..14fbbcb 100644 --- a/app/Services/DingTalkService.php +++ b/app/Services/DingTalkService.php @@ -20,7 +20,11 @@ class DingTalkService public function sendText(string $message, array $atMobiles = [], bool $atAll = false): void { if (empty($this->webhook)) { - Log::warning('DingTalk webhook is not configured, skip sending alert.'); + Log::warning('DingTalk webhook is not configured, skip sending alert. Alert content logged below.', [ + 'message' => $message, + 'atMobiles' => $atMobiles, + 'atAll' => $atAll, + ]); return; } diff --git a/app/Services/GitMonitorService.php b/app/Services/GitMonitorService.php index 32fbc05..2b3ee31 100644 --- a/app/Services/GitMonitorService.php +++ b/app/Services/GitMonitorService.php @@ -66,12 +66,17 @@ class GitMonitorService } try { - $version = $this->jiraService->getUpcomingReleaseVersion($projectKey); + // 先从 master 分支获取当前版本号 + $currentVersion = $this->getMasterVersion($repoKey, $repoConfig); + + // 根据当前版本号获取下一个版本 + $version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion); if ($version) { $payload['repositories'][$repoKey] = [ 'version' => $version['version'], 'release_date' => $version['release_date'], 'branch' => 'release/' . $version['version'], + 'current_version' => $currentVersion, ]; } } catch (\Throwable $e) { @@ -181,16 +186,23 @@ class GitMonitorService ]; foreach ($commits as $commit) { - if ($this->isDevelopMerge($path, $commit)) { + $isMerge = $this->isMergeCommit($path, $commit); + $isConflictResolution = $this->isConflictResolution($path, $commit); + + // 只检测直接从 develop 合并的情况 + if ($isMerge && $this->isDevelopMerge($path, $commit)) { $issues['develop_merges'][] = $this->getCommitMetadata($path, $commit); } - $missingFunctions = $this->detectMissingFunctions($path, $commit); - if (!empty($missingFunctions)) { - $issues['missing_functions'][] = [ - 'commit' => $this->getCommitMetadata($path, $commit), - 'details' => $missingFunctions, - ]; + // 只在 merge 提交或冲突解决提交中检测缺失函数 + if ($isMerge || $isConflictResolution) { + $missingFunctions = $this->detectMissingFunctions($path, $commit); + if (!empty($missingFunctions)) { + $issues['missing_functions'][] = [ + 'commit' => $this->getCommitMetadata($path, $commit), + 'details' => $missingFunctions, + ]; + } } } @@ -239,34 +251,52 @@ class GitMonitorService return array_values(array_filter(array_map('trim', explode("\n", $output)))); } - private function isDevelopMerge(string $repoPath, string $commit): bool + /** + * 判断是否为 merge 提交(有多个父提交) + */ + private function isMergeCommit(string $repoPath, string $commit): bool { $parents = trim($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%P', $commit])); $parentShas = array_values(array_filter(explode(' ', $parents))); - if (count($parentShas) < 2) { - return false; + return count($parentShas) >= 2; + } + + /** + * 判断是否为冲突解决提交 + * 通过检查 commit message 是否包含冲突相关关键词 + */ + private function isConflictResolution(string $repoPath, string $commit): bool + { + $message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit])); + + $conflictKeywords = ['conflict', 'resolve', '冲突', '解决冲突']; + foreach ($conflictKeywords as $keyword) { + if (str_contains($message, $keyword)) { + return true; + } } + return false; + } + + /** + * 判断是否为直接从 develop 分支合并到 release 的提交 + * 只检测 commit message 明确包含 "merge.*develop" 或 "develop.*into" 的情况 + */ + private function isDevelopMerge(string $repoPath, string $commit): bool + { $message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit])); - if (str_contains($message, self::DEVELOP_BRANCH)) { + + // 检测 "Merge branch 'develop'" 或 "merge develop into" 等模式 + // 排除 feature/xxx、hotfix/xxx 等分支的合并 + if (preg_match("/merge\s+(branch\s+)?['\"]?develop['\"]?(\s+into)?/i", $message)) { return true; } - $target = 'origin/' . self::DEVELOP_BRANCH; - - foreach ($parentShas as $parent) { - $branches = $this->runGit($repoPath, ['git', 'branch', '-r', '--contains', $parent]); - foreach (preg_split('/\R/', $branches) as $branchLine) { - $branchLine = trim(str_replace('*', '', $branchLine)); - if (empty($branchLine)) { - continue; - } - - if ($branchLine === $target || str_ends_with($branchLine, '/' . self::DEVELOP_BRANCH)) { - return true; - } - } + // 检测 "develop into release" 模式 + if (preg_match("/develop\s+into\s+['\"]?release/i", $message)) { + return true; } return false; @@ -331,11 +361,15 @@ class GitMonitorService private function extractFunctionName(string $line): ?string { $trimmed = trim($line); - if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*')) { + + // 跳过注释行 + if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*') || str_starts_with($trimmed, '/*')) { return null; } - if (preg_match('/function\s+([A-Za-z0-9_]+)\s*\(/', $trimmed, $matches)) { + // 只匹配真正的函数定义,必须以 function 关键字开头,或者以访问修饰符开头 + // 匹配: function foo(, public function foo(, private static function foo( 等 + if (preg_match('/^(?:(?:public|protected|private)\s+)?(?:static\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/', $trimmed, $matches)) { return $matches[1]; } @@ -495,4 +529,29 @@ class GitMonitorService $directory = $repoConfig['directory'] ?? $repoKey; return $this->projectsPath . '/' . ltrim($directory, '/'); } + + /** + * 从 master 分支读取 version.txt 获取当前版本号 + */ + public function getMasterVersion(string $repoKey, array $repoConfig): ?string + { + $path = $this->resolveProjectPath($repoKey, $repoConfig); + + if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { + Log::warning('Invalid git repository path', ['repository' => $repoKey, 'path' => $path]); + return null; + } + + try { + $this->runGit($path, ['git', 'fetch', 'origin', 'master']); + $version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']); + return trim($version) ?: null; + } catch (ProcessFailedException $e) { + Log::warning('Failed to read version.txt from master branch', [ + 'repository' => $repoKey, + 'error' => $e->getMessage(), + ]); + return null; + } + } } diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index 51596a1..bd3b3e5 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -758,9 +758,13 @@ class JiraService } /** - * 获取最近的 release 版本 + * 获取下一个 release 版本 + * 根据当前版本号,在 Jira 版本列表中找到下一个版本 + * + * @param string $projectKey Jira 项目 key + * @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt) */ - public function getUpcomingReleaseVersion(string $projectKey): ?array + public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array { try { $versions = $this->projectService->getVersions($projectKey); @@ -772,23 +776,66 @@ class JiraService return null; } - $now = Carbon::now()->startOfDay(); + // 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0) + $sortedVersions = collect($versions) + ->filter(fn($version) => !empty($version->name)) + ->sortBy(fn($version) => $version->name, SORT_NATURAL) + ->values(); - $candidate = collect($versions) - ->filter(function ($version) use ($now) { - if (($version->released ?? false) || empty($version->releaseDate)) { - return false; - } + if ($sortedVersions->isEmpty()) { + return null; + } - try { - return Carbon::parse($version->releaseDate)->greaterThanOrEqualTo($now); - } catch (\Throwable) { - return false; - } - }) - ->sortBy(function ($version) { - return Carbon::parse($version->releaseDate); - }) + // 如果没有提供当前版本,返回第一个未发布的版本 + if (empty($currentVersion)) { + $candidate = $sortedVersions + ->filter(fn($version) => !($version->released ?? false)) + ->first(); + + if (!$candidate) { + return null; + } + + return [ + 'version' => $candidate->name, + 'release_date' => !empty($candidate->releaseDate) + ? Carbon::parse($candidate->releaseDate)->toDateString() + : null, + ]; + } + + // 找到当前版本在列表中的位置,返回下一个版本 + $currentIndex = $sortedVersions->search( + fn($version) => $version->name === $currentVersion + ); + + // 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本 + if ($currentIndex === false) { + $candidate = $sortedVersions + ->filter(function ($version) use ($currentVersion) { + if ($version->released ?? false) { + return false; + } + return version_compare($version->name, $currentVersion, '>'); + }) + ->first(); + + if (!$candidate) { + return null; + } + + return [ + 'version' => $candidate->name, + 'release_date' => !empty($candidate->releaseDate) + ? Carbon::parse($candidate->releaseDate)->toDateString() + : null, + ]; + } + + // 从当前版本的下一个开始,找到第一个未发布的版本 + $candidate = $sortedVersions + ->slice($currentIndex + 1) + ->filter(fn($version) => !($version->released ?? false)) ->first(); if (!$candidate) { @@ -797,7 +844,9 @@ class JiraService return [ 'version' => $candidate->name, - 'release_date' => Carbon::parse($candidate->releaseDate)->toDateString(), + 'release_date' => !empty($candidate->releaseDate) + ? Carbon::parse($candidate->releaseDate)->toDateString() + : null, ]; } } diff --git a/app/Services/LogAnalysisService.php b/app/Services/LogAnalysisService.php new file mode 100644 index 0000000..663d5a5 --- /dev/null +++ b/app/Services/LogAnalysisService.php @@ -0,0 +1,437 @@ + $from, + 'to_time' => $to, + 'query' => $effectiveQuery, + 'mode' => $mode->value, + 'total_logs' => 0, + 'results' => [], + 'metadata' => [], + 'status' => 'pending', + ]); + + // 分发后台任务 + LogAnalysisJob::dispatch( + $report->id, + $from, + $to, + $effectiveQuery, + $mode, + $pushNotification + ); + + return $report; + } + + /** + * 执行日志分析 + * + * @param Carbon $from 开始时间 + * @param Carbon $to 结束时间 + * @param string|null $query SLS 查询语句 + * @param AnalysisMode $mode 分析模式 + * @param bool $saveReport 是否保存报告 + * @return array 分析结果 + */ + public function analyze( + Carbon $from, + Carbon $to, + ?string $query = null, + AnalysisMode $mode = AnalysisMode::Logs, + bool $saveReport = true + ): array { + $startTime = microtime(true); + + // 1. 获取日志 + $logs = $this->slsService->fetchLogs($from, $to, $query); + + if ($logs->isEmpty()) { + return $this->buildEmptyResult($from, $to, $query, $mode); + } + + // 2. 按 app_name 分组 + $grouped = $this->slsService->groupByAppName($logs); + + // 3. 分析每个分组 + $results = []; + $settings = $this->configService->get('log_analysis.settings', []); + $maxLogsPerApp = $settings['max_logs_per_app'] ?? 500; + + foreach ($grouped as $appName => $appLogs) { + $appLogsCollection = collect($appLogs); + + // 限制每个 app 的日志数量 + if ($appLogsCollection->count() > $maxLogsPerApp) { + $appLogsCollection = $appLogsCollection->take($maxLogsPerApp); + } + + // 获取代码上下文(如果需要) + $codeContext = null; + if ($mode === AnalysisMode::LogsWithCode) { + $repoPath = $this->codeContextService->getRepoPath($appName); + if ($repoPath) { + $codeContext = $this->codeContextService->extractRelevantCode($repoPath, $appLogsCollection); + } + } + + // 准备日志内容 + $logsContent = $this->formatLogsForAnalysis($appLogsCollection); + + // AI 分析 + try { + $results[$appName] = $this->aiService->analyzeLogs($logsContent, $codeContext); + $results[$appName]['log_count'] = $appLogsCollection->count(); + $results[$appName]['has_code_context'] = $codeContext !== null; + } catch (\Exception $e) { + $results[$appName] = [ + 'error' => $e->getMessage(), + 'log_count' => $appLogsCollection->count(), + 'has_code_context' => false, + ]; + } + } + + $executionTime = (microtime(true) - $startTime) * 1000; + + // 4. 构建结果 + $result = [ + 'request' => [ + 'from' => $from->format('Y-m-d H:i:s'), + 'to' => $to->format('Y-m-d H:i:s'), + 'query' => $query, + 'mode' => $mode->value, + ], + 'results' => $results, + 'metadata' => [ + 'total_logs' => $logs->count(), + 'apps_analyzed' => count($results), + 'execution_time_ms' => round($executionTime), + 'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'), + ], + ]; + + // 5. 保存报告 + if ($saveReport) { + $this->saveReport($result, $from, $to, $query, $mode, $logs->count()); + } + + return $result; + } + + /** + * 仅查询日志(不进行 AI 分析) + * + * @param Carbon $from + * @param Carbon $to + * @param string|null $query + * @param int|null $limit + * @return array + */ + public function queryLogs( + Carbon $from, + Carbon $to, + ?string $query = null, + ?int $limit = null + ): array { + $logs = $this->slsService->fetchLogs($from, $to, $query, $limit); + $grouped = $this->slsService->groupByAppName($logs); + $statistics = $this->slsService->getStatistics($logs); + + return [ + 'total' => $logs->count(), + 'statistics' => $statistics, + 'grouped' => array_map(fn($g) => count($g), $grouped), + 'logs' => $logs->take(100)->values()->toArray(), + ]; + } + + /** + * 从多个 logstore 查询日志(不进行 AI 分析) + * + * @param Carbon $from + * @param Carbon $to + * @param string|null $query + * @param int|null $limit + * @param array|null $logstores + * @return array + */ + public function queryLogsFromMultipleStores( + Carbon $from, + Carbon $to, + ?string $query = null, + ?int $limit = null, + ?array $logstores = null + ): array { + $logstoreData = $this->slsService->fetchLogsFromMultipleStores($from, $to, $query, $limit, $logstores); + $statistics = $this->slsService->getMultiStoreStatistics($logstoreData); + + // 合并所有 logstore 的日志用于预览 + $allLogs = collect(); + $groupedByLogstore = []; + + foreach ($logstoreData as $logstore => $data) { + if ($data['success']) { + $allLogs = $allLogs->merge($data['logs']); + $groupedByLogstore[$logstore] = [ + 'count' => $data['count'], + 'success' => true, + ]; + } else { + $groupedByLogstore[$logstore] = [ + 'count' => 0, + 'success' => false, + 'error' => $data['error'], + ]; + } + } + + return [ + 'total' => $statistics['total'], + 'statistics' => $statistics, + 'grouped_by_logstore' => $groupedByLogstore, + 'logs' => $allLogs->take(100)->values()->toArray(), + ]; + } + + /** + * 获取历史报告列表 + * + * @param int $limit + * @param int $offset + * @return array + */ + public function getReports(int $limit = 20, int $offset = 0): array + { + $query = LogAnalysisReport::query() + ->orderBy('created_at', 'desc'); + + $total = $query->count(); + $reports = $query->skip($offset)->take($limit)->get(); + + return [ + 'total' => $total, + 'reports' => $reports->map(function ($report) { + return [ + 'id' => $report->id, + 'from_time' => $report->from_time->format('Y-m-d H:i:s'), + 'to_time' => $report->to_time->format('Y-m-d H:i:s'), + 'query' => $report->query, + 'mode' => $report->mode, + 'total_logs' => $report->total_logs, + 'status' => $report->status, + 'created_at' => $report->created_at->format('Y-m-d H:i:s'), + ]; + })->toArray(), + ]; + } + + /** + * 获取单个报告详情 + * + * @param int $id + * @return array|null + */ + public function getReport(int $id): ?array + { + $report = LogAnalysisReport::find($id); + + if (!$report) { + return null; + } + + return [ + 'id' => $report->id, + 'request' => [ + 'from' => $report->from_time->format('Y-m-d H:i:s'), + 'to' => $report->to_time->format('Y-m-d H:i:s'), + 'query' => $report->query, + 'mode' => $report->mode, + ], + 'results' => $report->results, + 'metadata' => $report->metadata, + 'status' => $report->status, + 'error_message' => $report->error_message, + 'created_at' => $report->created_at->format('Y-m-d H:i:s'), + ]; + } + + /** + * 推送分析结果到钉钉 + * + * @param array $result 分析结果 + * @return bool + */ + public function pushToNotification(array $result): bool + { + $message = $this->formatNotificationMessage($result); + + try { + $this->dingTalkService->sendText($message); + return true; + } catch (\Exception $e) { + return false; + } + } + + /** + * 格式化日志用于 AI 分析 + */ + private function formatLogsForAnalysis(Collection $logs): string + { + $formatted = []; + + foreach ($logs as $log) { + $line = sprintf( + "[%s] [%s] %s", + $log['time'] ?? 'N/A', + $log['level'] ?? 'N/A', + $log['message'] ?? json_encode($log['_raw'] ?? $log) + ); + + if (!empty($log['trace'])) { + $line .= "\n" . $log['trace']; + } + + $formatted[] = $line; + } + + return implode("\n\n", $formatted); + } + + /** + * 格式化通知消息 + */ + private function formatNotificationMessage(array $result): string + { + $lines = []; + $lines[] = "📊 SLS 日志分析报告"; + $lines[] = "时间范围: {$result['request']['from']} ~ {$result['request']['to']}"; + $lines[] = "总日志数: {$result['metadata']['total_logs']}"; + $lines[] = ""; + + foreach ($result['results'] as $appName => $appResult) { + $lines[] = "【{$appName}】"; + + if (isset($appResult['error'])) { + $lines[] = " 分析失败: {$appResult['error']}"; + continue; + } + + $impact = $appResult['impact'] ?? 'unknown'; + $impactEmoji = match ($impact) { + 'high' => '🔴', + 'medium' => '🟡', + 'low' => '🟢', + default => '⚪', + }; + + $lines[] = " 影响级别: {$impactEmoji} {$impact}"; + $lines[] = " 摘要: " . ($appResult['summary'] ?? 'N/A'); + + $anomalies = $appResult['core_anomalies'] ?? []; + if (!empty($anomalies)) { + $lines[] = " 异常数: " . count($anomalies); + foreach (array_slice($anomalies, 0, 3) as $anomaly) { + $lines[] = " - [{$anomaly['classification']}] {$anomaly['possible_cause']}"; + } + } + + $lines[] = ""; + } + + return implode("\n", $lines); + } + + /** + * 保存分析报告 + */ + private function saveReport( + array $result, + Carbon $from, + Carbon $to, + ?string $query, + AnalysisMode $mode, + int $totalLogs + ): LogAnalysisReport { + return LogAnalysisReport::create([ + 'from_time' => $from, + 'to_time' => $to, + 'query' => $query, + 'mode' => $mode->value, + 'total_logs' => $totalLogs, + 'results' => $result['results'], + 'metadata' => $result['metadata'], + 'status' => 'completed', + ]); + } + + /** + * 构建空结果 + */ + private function buildEmptyResult( + Carbon $from, + Carbon $to, + ?string $query, + AnalysisMode $mode + ): array { + return [ + 'request' => [ + 'from' => $from->format('Y-m-d H:i:s'), + 'to' => $to->format('Y-m-d H:i:s'), + 'query' => $query, + 'mode' => $mode->value, + ], + 'results' => [], + 'metadata' => [ + 'total_logs' => 0, + 'apps_analyzed' => 0, + 'execution_time_ms' => 0, + 'analyzed_at' => Carbon::now()->format('Y-m-d H:i:s'), + 'message' => '未找到匹配的日志', + ], + ]; + } +} diff --git a/app/Services/SlsService.php b/app/Services/SlsService.php new file mode 100644 index 0000000..c5e55c9 --- /dev/null +++ b/app/Services/SlsService.php @@ -0,0 +1,288 @@ +client->isConfigured(); + } + + /** + * 获取日志 + * + * @param Carbon $from 开始时间 + * @param Carbon $to 结束时间 + * @param string|null $query SLS 查询语句 + * @param int|null $limit 最大返回数量 + * @return Collection + */ + public function fetchLogs( + Carbon $from, + Carbon $to, + ?string $query = null, + ?int $limit = null + ): Collection { + $settings = $this->configService->get('log_analysis.settings', []); + $maxLogs = $limit ?? ($settings['max_logs_per_batch'] ?? 1000); + + $logs = $this->client->getAllLogs( + $from->timestamp, + $to->timestamp, + $query, + $maxLogs + ); + + return collect($logs)->map(function ($log) { + return $this->normalizeLog($log); + }); + } + + /** + * 从多个 logstore 获取日志 + * + * @param Carbon $from 开始时间 + * @param Carbon $to 结束时间 + * @param string|null $query SLS 查询语句 + * @param int|null $limit 最大返回数量 + * @param array|null $logstores 要查询的 logstore 列表 + * @return array 按 logstore 分组的日志数据 + */ + public function fetchLogsFromMultipleStores( + Carbon $from, + Carbon $to, + ?string $query = null, + ?int $limit = null, + ?array $logstores = null + ): array { + $settings = $this->configService->get('log_analysis.settings', []); + $maxLogs = $limit ?? ($settings['max_logs_per_batch'] ?? 1000); + + $results = $this->client->getAllLogsFromMultipleStores( + $from->timestamp, + $to->timestamp, + $query, + $maxLogs, + $logstores + ); + + // 标准化每个 logstore 的日志 + $normalized = []; + foreach ($results as $logstore => $data) { + $normalized[$logstore] = [ + 'logs' => collect($data['logs'])->map(function ($log) use ($logstore) { + $normalizedLog = $this->normalizeLog($log); + $normalizedLog['_logstore'] = $logstore; + return $normalizedLog; + }), + 'count' => $data['count'], + 'success' => $data['success'], + 'error' => $data['error'] ?? null, + ]; + } + + return $normalized; + } + + /** + * 获取配置的所有 logstore + */ + public function getLogstores(): array + { + return $this->client->getLogstores(); + } + + /** + * 按 app_name 分组日志 + * + * @param Collection $logs + * @return array + */ + public function groupByAppName(Collection $logs): array + { + $grouped = $logs->groupBy(function ($log) { + return $log['app_name'] ?? $log['__source__'] ?? 'unknown'; + }); + + return $grouped->map(function ($group) { + return $group->values(); + })->toArray(); + } + + /** + * 按日志级别过滤 + * + * @param Collection $logs + * @param array $levels 要保留的级别 ['ERROR', 'WARN', 'INFO'] + * @return Collection + */ + public function filterByLevel(Collection $logs, array $levels): Collection + { + $levels = array_map('strtoupper', $levels); + + return $logs->filter(function ($log) use ($levels) { + $level = strtoupper($log['level'] ?? $log['__level__'] ?? ''); + return in_array($level, $levels); + }); + } + + /** + * 获取日志分布直方图 + * + * @param Carbon $from + * @param Carbon $to + * @param string|null $query + * @return array + */ + public function getHistogram(Carbon $from, Carbon $to, ?string $query = null): array + { + return $this->client->getHistograms( + $from->timestamp, + $to->timestamp, + $query + ); + } + + /** + * 获取日志统计信息 + * + * @param Collection $logs + * @return array + */ + public function getStatistics(Collection $logs): array + { + $byLevel = $logs->groupBy(function ($log) { + return strtoupper($log['level'] ?? $log['__level__'] ?? 'UNKNOWN'); + })->map(fn ($group) => $group->count()); + + $byApp = $logs->groupBy(function ($log) { + return $log['app_name'] ?? $log['__source__'] ?? 'unknown'; + })->map(fn ($group) => $group->count()); + + return [ + 'total' => $logs->count(), + 'by_level' => $byLevel->toArray(), + 'by_app' => $byApp->toArray(), + ]; + } + + /** + * 获取多 logstore 的统计信息 + * + * @param array $logstoreData 按 logstore 分组的日志数据 + * @return array + */ + public function getMultiStoreStatistics(array $logstoreData): array + { + $totalCount = 0; + $byLogstore = []; + $byLevel = []; + $byApp = []; + + foreach ($logstoreData as $logstore => $data) { + if (!$data['success']) { + $byLogstore[$logstore] = [ + 'count' => 0, + 'success' => false, + 'error' => $data['error'], + ]; + continue; + } + + $logs = $data['logs']; + $count = $logs->count(); + $totalCount += $count; + + $byLogstore[$logstore] = [ + 'count' => $count, + 'success' => true, + ]; + + // 按级别统计 + $logs->groupBy(function ($log) { + return strtoupper($log['level'] ?? $log['__level__'] ?? 'UNKNOWN'); + })->each(function ($group, $level) use (&$byLevel) { + $byLevel[$level] = ($byLevel[$level] ?? 0) + $group->count(); + }); + + // 按应用统计 + $logs->groupBy(function ($log) { + return $log['app_name'] ?? $log['__source__'] ?? 'unknown'; + })->each(function ($group, $app) use (&$byApp) { + $byApp[$app] = ($byApp[$app] ?? 0) + $group->count(); + }); + } + + return [ + 'total' => $totalCount, + 'by_logstore' => $byLogstore, + 'by_level' => $byLevel, + 'by_app' => $byApp, + ]; + } + + /** + * 测试连接 + */ + public function testConnection(): bool + { + return $this->client->testConnection(); + } + + /** + * 标准化日志格式 + */ + private function normalizeLog(array $log): array + { + // 如果日志内容是 JSON 字符串(某些情况下 SLS 会将整条日志作为一个字段返回) + // 尝试解析 content 或 message 字段 + if (isset($log['content']) && is_string($log['content']) && str_starts_with(trim($log['content']), '{')) { + $decoded = json_decode($log['content'], true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $log = array_merge($log, $decoded); + } + } + + // 尝试提取常见字段,优先使用业务字段,其次使用 SLS 系统字段 + $normalized = [ + 'time' => $log['date'] ?? $log['time'] ?? $log['timestamp'] ?? $log['__time__'] ?? null, + 'level' => $log['level'] ?? $log['__level__'] ?? $log['severity'] ?? null, + 'message' => $log['message'] ?? $log['msg'] ?? $log['content'] ?? null, + 'app_name' => $log['app_name'] ?? $log['application'] ?? $log['service'] ?? $log['__source__'] ?? null, + 'trace' => $log['stack_trace'] ?? $log['trace'] ?? $log['exception'] ?? null, + 'file' => $log['file'] ?? $log['filename'] ?? null, + 'line' => $log['line'] ?? $log['lineno'] ?? null, + ]; + + // 格式化时间 + if (is_numeric($normalized['time'])) { + // Unix 时间戳 + $normalized['time'] = Carbon::createFromTimestamp($normalized['time'])->format('Y-m-d H:i:s'); + } elseif (is_string($normalized['time']) && !empty($normalized['time'])) { + // ISO 8601 格式或其他字符串格式 + try { + $normalized['time'] = Carbon::parse($normalized['time'])->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + // 如果解析失败,保持原样 + } + } + + // 保留原始数据 + $normalized['_raw'] = $log; + + return $normalized; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index e1f558b..a7c3878 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,5 @@ withExceptions(function (Exceptions $exceptions): void { // }) - ->withSchedule(function (Schedule $schedule): void { - $schedule->command('git-monitor:check') - ->everyTenMinutes() - ->withoutOverlapping() - ->runInBackground(); - - $schedule->command('git-monitor:cache') - ->dailyAt('02:00') - ->withoutOverlapping(); - }) ->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index e412c3d..38b258d 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,7 +2,4 @@ return [ App\Providers\AppServiceProvider::class, - App\Providers\ClientServiceProvider::class, - App\Providers\EnvServiceProvider::class, - App\Providers\GitMonitorServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 31b8114..271458c 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,4 @@ { - "$schema": "https://getcomposer.org/schema.json", "name": "laravel/laravel", "type": "project", "description": "The skeleton application for the Laravel framework.", @@ -7,10 +6,11 @@ "license": "MIT", "require": { "php": "^8.2", + "ext-pdo": "*", + "alibabacloud/aliyun-log-php-sdk": "^0.6.8", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", - "lesstif/php-jira-rest-client": "5.10.0", - "ext-pdo": "*" + "lesstif/php-jira-rest-client": "5.10.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index d158fc6..259a306 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5955fb8cae2c82cdb7352006103c8c0c", + "content-hash": "c0be44d46402c6a66259be9824335576", "packages": [ + { + "name": "alibabacloud/aliyun-log-php-sdk", + "version": "0.6.8", + "source": { + "type": "git", + "url": "https://github.com/alibabacloud-sdk-php/aliyun-log-php-sdk.git", + "reference": "a4ca1baf62570bf5ce84ad74ed3778414e3f5827" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/alibabacloud-sdk-php/aliyun-log-php-sdk/zipball/a4ca1baf62570bf5ce84ad74ed3778414e3f5827", + "reference": "a4ca1baf62570bf5ce84ad74ed3778414e3f5827", + "shasum": "" + }, + "require": { + "php": ">=7.1.7" + }, + "require-dev": { + "phpunit/phpunit": "^11.2" + }, + "type": "library", + "autoload": { + "files": [ + "Log_Autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Qi Zhou" + }, + { + "name": "TingTao Sun" + }, + { + "name": "Leiyun Ma" + }, + { + "name": "Xin Zhang" + } + ], + "description": "The Php SDK of Alibaba log service", + "keywords": [ + "SLS", + "alibaba", + "sdk" + ], + "support": { + "source": "https://github.com/alibabacloud-sdk-php/aliyun-log-php-sdk/tree/0.6.8" + }, + "time": "2025-03-03T07:49:29+00:00" + }, { "name": "brick/math", "version": "0.13.1", @@ -8216,7 +8271,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.2", + "ext-pdo": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/config/services.php b/config/services.php index 3ca7edb..60083a7 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,23 @@ return [ 'secret' => env('DINGTALK_SECRET'), ], + 'sls' => [ + 'endpoint' => env('SLS_ENDPOINT'), + 'project' => env('SLS_PROJECT'), + 'logstore' => env('SLS_LOGSTORE'), + 'access_key_id' => env('SLS_ACCESS_KEY_ID'), + 'access_key_secret' => env('SLS_ACCESS_KEY_SECRET'), + 'security_token' => env('SLS_SECURITY_TOKEN', ''), + 'query_timeout' => (int) env('SLS_QUERY_TIMEOUT', 60), + ], + + 'ai' => [ + 'endpoint' => env('AI_ENDPOINT', 'https://api.openai.com/v1'), + 'api_key' => env('AI_API_KEY'), + 'model' => env('AI_MODEL', 'gpt-4o'), + 'temperature' => (float) env('AI_TEMPERATURE', 0.3), + 'timeout' => (int) env('AI_TIMEOUT', 120), + 'max_tokens' => (int) env('AI_MAX_TOKENS', 4096), + ], + ]; diff --git a/database/migrations/2026_01_13_134328_create_log_analysis_reports_table.php b/database/migrations/2026_01_13_134328_create_log_analysis_reports_table.php new file mode 100644 index 0000000..4c678a3 --- /dev/null +++ b/database/migrations/2026_01_13_134328_create_log_analysis_reports_table.php @@ -0,0 +1,39 @@ +id(); + $table->timestamp('from_time'); + $table->timestamp('to_time'); + $table->string('query', 1000)->nullable(); + $table->string('mode', 20); + $table->integer('total_logs'); + $table->json('results'); + $table->json('metadata')->nullable(); + $table->string('status', 20)->default('completed'); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->index(['from_time', 'to_time']); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('log_analysis_reports'); + } +}; diff --git a/resources/js/components/admin/AdminDashboard.vue b/resources/js/components/admin/AdminDashboard.vue index e587329..770f69c 100644 --- a/resources/js/components/admin/AdminDashboard.vue +++ b/resources/js/components/admin/AdminDashboard.vue @@ -46,6 +46,12 @@ ref="messageDispatch" /> + + + @@ -66,6 +72,7 @@ import JiraWorklog from '../jira/JiraWorklog.vue'; import MessageSync from '../message-sync/MessageSync.vue'; import EventConsumerSync from '../message-sync/EventConsumerSync.vue'; import MessageDispatch from '../message-sync/MessageDispatch.vue'; +import LogAnalysis from '../log-analysis/LogAnalysis.vue'; import SystemSettings from './SystemSettings.vue'; import OperationLogs from './OperationLogs.vue'; import IpUserMappings from './IpUserMappings.vue'; @@ -81,6 +88,7 @@ export default { MessageSync, EventConsumerSync, MessageDispatch, + LogAnalysis, SystemSettings, OperationLogs, IpUserMappings @@ -129,6 +137,7 @@ export default { 'message-sync': '消息同步', 'event-consumer-sync': '事件消费者同步对比', 'message-dispatch': '消息分发异常查询', + 'log-analysis': 'SLS 日志分析', 'settings': '系统设置', 'logs': '操作日志', 'ip-mappings': 'IP 用户映射' @@ -155,6 +164,8 @@ export default { page = 'event-consumer-sync'; } else if (path === '/message-dispatch') { page = 'message-dispatch'; + } else if (path === '/log-analysis') { + page = 'log-analysis'; } else if (path === '/settings') { page = 'settings'; } else if (path === '/logs') { diff --git a/resources/js/components/admin/AdminLayout.vue b/resources/js/components/admin/AdminLayout.vue index 8609511..d8c1685 100644 --- a/resources/js/components/admin/AdminLayout.vue +++ b/resources/js/components/admin/AdminLayout.vue @@ -190,6 +190,30 @@ 消息分发异常 + + + + + SLS 日志分析 + + +
+ +
+

SLS 日志分析

+

查询和分析阿里云 SLS 日志,支持 AI 智能分析

+
+ + +
+
+ + + + SLS 服务未配置,请在 .env 中设置 SLS_* 配置项 +
+
+ +
+
+ + + + AI 服务未配置,请在下方配置 AI 提供商 +
+
+ + +
+

查询条件

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + +
+
+
+ + +
+ + +
+
+ 查询语法说明 +
+

全文搜索:

+

* - 查询所有日志

+

ERROR - 搜索包含 ERROR 的日志

+

ERROR and timeout - 同时包含两个关键词

+

ERROR or WARNING - 包含任一关键词

+

ERROR not success - 包含 ERROR 但不包含 success

+

SQL 分析语法(需要字段索引):

+

* | where level = "ERROR" - 筛选 level 字段

+

* | where level in ("ERROR", "WARNING") - 多个值

+

* | where status >= 500 - 数值比较

+
+
+
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+ + +
+
+ + +
+ +
+ + +
+ {{ error }} +
+ + +
+
+ 请输入查询条件并点击"查询日志" +
+
+ +
+
+
+
+ 总日志数: {{ queryResult.total }} + + {{ level }}: {{ count }} + +
+
+ +
+ 按 Logstore: + + {{ logstore }}: {{ data.count }} + (失败) + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + +
时间级别应用Logstore消息
{{ log.time }} + + {{ log.level }} + + {{ log.app_name }} + {{ log._logstore || '-' }} + {{ log.message }}
+
+
+
+ + +
+
+ 请在历史报告中选择一个已完成的分析报告查看结果 +
+
+ +
+
+ + + + + 分析任务正在处理中,请稍候... + +
+
+
+
+ 分析失败: {{ analysisResult.error_message }} +
+
+ + +
+ 分析时间: {{ analysisResult.metadata?.analyzed_at || analysisResult.created_at }} + 总日志: {{ analysisResult.metadata?.total_logs ?? analysisResult.total_logs ?? 0 }} + 耗时: {{ analysisResult.metadata.execution_time_ms }}ms + + 状态: + + {{ getStatusText(analysisResult.status) }} + + +
+ + +
+
+
+

{{ appName }}

+ + {{ result.impact || 'N/A' }} + +
+
+
+ 分析失败: {{ result.error }} +
+
+

{{ result.summary }}

+ + +
+

异常列表 ({{ result.core_anomalies.length }})

+
+
+ {{ anomaly.type }} + {{ anomaly.classification }} + x{{ anomaly.count }} +
+

可能原因: {{ anomaly.possible_cause }}

+

建议: {{ anomaly.suggestion }}

+
+
+
+
+
+
+
+ 未找到匹配的日志或分析结果为空 +
+
+
+ + +
+
+

历史报告

+ +
+
+ 加载中... +
+
+ 暂无历史报告 +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
ID时间范围模式日志数状态创建时间操作
#{{ report.id }}{{ report.from_time }} ~ {{ report.to_time }}{{ report.mode }}{{ report.total_logs }} + + {{ getStatusText(report.status) }} + + {{ report.created_at }} + +
+
+
+ + +
+

AI 提供商配置

+ + +
+ 当前使用: {{ config.active_ai_provider.name || config.active_ai_provider.key }} +
+ + +
+
+
+
+ {{ provider.name || key }} + {{ provider.model }} +
+
+ + +
+
+
+ Endpoint: {{ provider.endpoint }} +
+
+
+ + +
+

添加新的 AI 提供商

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + {{ aiTestResult.message }} + +
+ + +
+

定时任务设置

+
+ +
+
+ 每日自动分析 +

每天凌晨 2 点自动分析过去 24 小时的 ERROR 日志并推送到钉钉

+
+ +
+ + +
+
+ 高频分析(每 4 小时) +

每 4 小时自动分析过去 6 小时的 ERROR 日志并推送到钉钉

+
+ +
+ +
+

+ 注意:需要配置 Laravel 调度器才能生效。请确保服务器已配置 cron 任务:
+ * * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1 +

+

+ 同时需要启动队列处理器来执行后台任务:
+ php artisan queue:work +

+
+
+
+
+
+ + + diff --git a/routes/api.php b/routes/api.php index 379a4e9..438e41f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\EnvController; use App\Http\Controllers\JiraController; +use App\Http\Controllers\LogAnalysisController; use App\Http\Controllers\MessageSyncController; use App\Http\Controllers\MessageDispatchController; use App\Http\Controllers\SqlGeneratorController; @@ -76,3 +77,30 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () { Route::put('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'update']); Route::delete('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'destroy']); }); + +// 日志分析 API 路由 +Route::prefix('log-analysis')->group(function () { + // 日志查询(预览) + Route::post('/query', [LogAnalysisController::class, 'queryLogs']); + + // AI 分析 + Route::post('/analyze', [LogAnalysisController::class, 'analyze']); + + // 历史报告 + Route::get('/reports', [LogAnalysisController::class, 'listReports']); + Route::get('/reports/{id}', [LogAnalysisController::class, 'getReport']); + + // 配置(读取不需要管理员权限) + Route::get('/config', [LogAnalysisController::class, 'getConfig']); + + // 获取 logstore 列表 + Route::get('/logstores', [LogAnalysisController::class, 'getLogstores']); + + // 连接测试 + Route::get('/test-sls', [LogAnalysisController::class, 'testSlsConnection']); + Route::get('/test-ai', [LogAnalysisController::class, 'testAiConnection']); + + // 配置更新(需要管理员权限) + Route::put('/config', [LogAnalysisController::class, 'updateConfig']) + ->middleware('admin.ip'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..1cdb7c9 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,76 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +/* +|-------------------------------------------------------------------------- +| Scheduled Tasks +|-------------------------------------------------------------------------- +| +| 定时任务配置 +| 所有定时任务统一在此文件管理 +| +*/ + +// Git Monitor - 每 10 分钟检查 release 分支 +Schedule::command('git-monitor:check') + ->everyTenMinutes() + ->withoutOverlapping() + ->runInBackground() + ->name('git-monitor-check'); + +// Git Monitor - 每天凌晨 2 点刷新 release 缓存 +Schedule::command('git-monitor:cache') + ->dailyAt('02:00') + ->withoutOverlapping() + ->name('git-monitor-cache'); + +// SLS 日志分析定时任务 - 每天凌晨 2 点执行 +// 分析过去 24 小时的 ERROR 和 WARNING 日志并推送到钉钉 +// 可通过数据库配置 log_analysis.settings.daily_schedule_enabled 控制是否启用 +/* +Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or WARNING" --push') + ->dailyAt('02:00') + ->withoutOverlapping() + ->runInBackground() + ->when(function () { + try { + $settings = app(ConfigService::class)->get('log_analysis.settings', []); + return $settings['daily_schedule_enabled'] ?? false; + } catch (\Exception $e) { + return false; + } + }) + ->name('daily-log-analysis') + ->onFailure(function () { + Log::error('每日日志分析定时任务执行失败'); + }); +*/ + +// SLS 日志分析定时任务 - 每 4 小时执行一次 +// 分析过去 6 小时的 ERROR 和 WARNING 日志并推送到钉钉 +// 可通过数据库配置 log_analysis.settings.schedule_enabled 控制是否启用 +Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WARNING" --push') + ->everyFourHours() + ->withoutOverlapping() + ->runInBackground() + ->when(function () { + try { + $settings = app(ConfigService::class)->get('log_analysis.settings', []); + return $settings['schedule_enabled'] ?? false; + } catch (\Exception $e) { + return false; + } + }) + ->name('frequent-log-analysis') + ->onFailure(function () { + Log::error('SLS 日志分析定时任务执行失败'); + }); diff --git a/routes/web.php b/routes/web.php index 5e8b09a..7832f0e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog') Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync'); Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-sync'); Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch'); +Route::get('/log-analysis', [AdminController::class, 'index'])->name('admin.log-analysis'); Route::get('/settings', [AdminController::class, 'index'])->name('admin.settings'); Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs'); Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');