diff --git a/.env.example b/.env.example index fca9a92..460fa93 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,13 @@ AI_TEMPERATURE=0.3 AI_TIMEOUT=120 AI_MAX_TOKENS=4096 +# Gemini CLI Configuration (用于代码分析) +# 获取 API Key: https://aistudio.google.com/apikey +GEMINI_API_KEY= + +# Proxy Configuration (用于后台任务访问外网) +PROXY_URL= + DINGTALK_WEBHOOK= DINGTALK_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c2f6fc6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +Tradewind Toolbox 是一个基于 Laravel 12 的内部工具管理平台,提供 Vue 3 单页应用前端和 RESTful API 后端。主要功能模块包括: + +- **环境管理** - .env 文件的保存、应用、备份、恢复 +- **JIRA 集成** - 周报生成、工时日志查询 +- **消息同步** - 跨系统消息队列同步和对比 +- **消息分发** - 消息路由配置管理 +- **日志分析** - 阿里云 SLS 日志查询 + AI 分析 +- **Git 监控** - Release 分支检查、冲突检测 +- **Jenkins 监控** - 构建状态监控和钉钉通知 + +## 常用命令 + +```bash +# 开发环境(同时启动后端、队列、日志、前端) +composer dev + +# 运行测试 +composer test + +# PHP 代码格式化 +./vendor/bin/pint + +# 数据库迁移 +php artisan migrate + +# 清除缓存 +php artisan optimize:clear + +# 前端构建 +npm run build +``` + +## 核心架构 + +### 服务层 (`app/Services/`) + +业务逻辑集中在 Services 目录,所有服务在 `AppServiceProvider` 中注册为单例: + +| 服务 | 职责 | +|------|------| +| `ConfigService` | 数据库键值配置存储 | +| `JiraService` | JIRA REST API 集成 | +| `SlsService` | 阿里云 SLS 日志查询 | +| `AiService` | AI 提供商管理(支持 OpenAI 兼容接口) | +| `LogAnalysisService` | 日志分析编排(SLS → AI → 代码分析) | +| `CodeAnalysisService` | 代码级分析(调用 Gemini/Claude CLI) | +| `GitMonitorService` | Git 仓库监控 | +| `JenkinsMonitorService` | Jenkins 构建监控 | +| `DingTalkService` | 钉钉 Webhook 通知 | +| `EnvService` | .env 文件管理 | +| `ScheduledTaskService` | 定时任务动态控制 | + +### 外部客户端 (`app/Clients/`) + +封装外部服务调用:`AiClient`、`SlsClient`、`JenkinsClient`、`AgentClient`、`MonoClient` + +### 定时任务 (`routes/console.php`) + +所有定时任务可在管理后台动态启用/禁用,状态存储在 `configs` 表: + +- `git-monitor:check` - 每 10 分钟检查 release 分支 +- `git-monitor:cache` - 每天 02:00 刷新 release 缓存 +- `log-analysis:run` - 每天 02:00 执行日志+代码分析 +- `jenkins:monitor` - 每分钟检查 Jenkins 构建 + +### 队列任务 (`app/Jobs/`) + +`LogAnalysisJob` - 后台执行日志分析:获取日志 → 按 app 分组 → AI 分析 → 代码分析 → 保存报告 → 推送通知 + +### 路由结构 + +- **Web 路由** (`routes/web.php`) - 所有页面通过 `AdminController@index` 渲染 Vue SPA +- **API 路由** (`routes/api.php`) - RESTful API,按模块分组(env、jira、log-analysis、admin 等) +- **中间件** - `AdminIpMiddleware` IP 白名单、`OperationLogMiddleware` 操作审计 + +## 技术栈 + +- **后端**: PHP 8.2+, Laravel 12, PHPUnit 11 +- **前端**: Vue 3, Vite 7, Tailwind CSS 4, CodeMirror 6 +- **数据库**: SQLite (默认) / MySQL +- **队列**: Database 驱动 +- **外部集成**: JIRA、阿里云 SLS、OpenAI 兼容 API、钉钉、Jenkins diff --git a/app/Clients/AiClient.php b/app/Clients/AiClient.php index 850c7a3..625391c 100644 --- a/app/Clients/AiClient.php +++ b/app/Clients/AiClient.php @@ -140,7 +140,7 @@ class AiClient ]); if ($response->successful()) { - return $response->json('choices.0.message.content', ''); + return $response->json('choices.0.message.content') ?? ''; } // 处理 429 Too Many Requests 错误 diff --git a/app/Clients/MonoClient.php b/app/Clients/MonoClient.php index 5bb1c8e..c95dbe1 100644 --- a/app/Clients/MonoClient.php +++ b/app/Clients/MonoClient.php @@ -33,5 +33,15 @@ class MonoClient { return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/update-dispatch', $data); } + + /** + * 手动消费指定消息(由mono从CRM获取消息并进行分发) + */ + public function consumeMessage(string $msgId): Response + { + return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/consume', [ + 'msg_id' => $msgId, + ]); + } } diff --git a/app/Clients/SlsClient.php b/app/Clients/SlsClient.php index 316975e..454abc0 100644 --- a/app/Clients/SlsClient.php +++ b/app/Clients/SlsClient.php @@ -82,6 +82,7 @@ class SlsClient * @param int $offset 偏移量 * @param int $limit 返回数量 * @param string|null $logstore 可选的 logstore,不传则使用默认 + * @param int $maxRetries 最大重试次数 * @return array{logs: array, count: int, complete: bool} */ public function getLogs( @@ -90,7 +91,8 @@ class SlsClient ?string $query = null, int $offset = 0, int $limit = 100, - ?string $logstore = null + ?string $logstore = null, + int $maxRetries = 3 ): array { $this->ensureConfigured(); @@ -106,26 +108,47 @@ class SlsClient false ); - try { - $response = $this->client->getLogs($request); + $lastException = null; + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + $response = $this->client->getLogs($request); - $logs = []; - foreach ($response->getLogs() as $log) { - $logs[] = $log->getContents(); + $logs = []; + foreach ($response->getLogs() as $log) { + $logs[] = $log->getContents(); + } + + return [ + 'logs' => $logs, + 'count' => $response->getCount(), + 'complete' => $response->isCompleted(), + ]; + } catch (Aliyun_Log_Exception $e) { + $lastException = $e; + $errorCode = $e->getErrorCode(); + + // 对于 5xx 错误或 RequestError,进行重试 + if (str_contains($errorCode, 'RequestError') || str_contains($e->getErrorMessage(), '50')) { + if ($attempt < $maxRetries) { + sleep(pow(2, $attempt)); // 指数退避: 2, 4, 8 秒 + continue; + } + } + + // 其他错误直接抛出 + throw new RuntimeException( + "SLS 查询失败: [{$errorCode}] {$e->getErrorMessage()}", + 0, + $e + ); } - - return [ - 'logs' => $logs, - 'count' => $response->getCount(), - 'complete' => $response->isCompleted(), - ]; - } catch (Aliyun_Log_Exception $e) { - throw new RuntimeException( - "SLS 查询失败: [{$e->getErrorCode()}] {$e->getErrorMessage()}", - 0, - $e - ); } + + throw new RuntimeException( + "SLS 查询失败: [{$lastException->getErrorCode()}] {$lastException->getErrorMessage()}", + 0, + $lastException + ); } /** diff --git a/app/Console/Commands/LogAnalysisCommand.php b/app/Console/Commands/LogAnalysisCommand.php index 4f5feb1..456e97e 100644 --- a/app/Console/Commands/LogAnalysisCommand.php +++ b/app/Console/Commands/LogAnalysisCommand.php @@ -31,12 +31,12 @@ class LogAnalysisCommand extends Command // 检查配置 if (!$slsService->isConfigured()) { Log::channel('log-analysis')->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项'); - return Command::FAILURE; + return self::FAILURE; } if (!$aiService->isConfigured()) { Log::channel('log-analysis')->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项'); - return Command::FAILURE; + return self::FAILURE; } // 解析时间参数 @@ -45,7 +45,7 @@ class LogAnalysisCommand extends Command if ($from >= $to) { Log::channel('log-analysis')->error('开始时间必须早于结束时间'); - return Command::FAILURE; + return self::FAILURE; } // 解析分析模式 @@ -91,10 +91,10 @@ class LogAnalysisCommand extends Command // 显示摘要 $this->displaySummary($result); - return Command::SUCCESS; + return self::SUCCESS; } catch (\Exception $e) { Log::channel('log-analysis')->error("分析失败: {$e->getMessage()}"); - return Command::FAILURE; + return self::FAILURE; } } @@ -153,7 +153,6 @@ class LogAnalysisCommand extends Command $impact = $appResult['impact'] ?? 'unknown'; Log::channel('log-analysis')->info(" 日志数: {$appResult['log_count']}"); - Log::channel('log-analysis')->info(" 代码上下文: " . ($appResult['has_code_context'] ? '是' : '否')); Log::channel('log-analysis')->info(" 影响级别: {$impact}"); Log::channel('log-analysis')->info(" 摘要: " . ($appResult['summary'] ?? 'N/A')); diff --git a/app/Jobs/LogAnalysisJob.php b/app/Jobs/LogAnalysisJob.php index e2c5503..666c574 100644 --- a/app/Jobs/LogAnalysisJob.php +++ b/app/Jobs/LogAnalysisJob.php @@ -101,8 +101,7 @@ class LogAnalysisJob implements ShouldQueue if (in_array($impact, ['high', 'medium'])) { $codeAnalysisResult = $codeAnalysisService->analyze( $appName, - $logsContent, - $results[$appName]['summary'] ?? null + $results[$appName] ); $results[$appName]['code_analysis'] = $codeAnalysisResult; } diff --git a/app/Services/CodeAnalysisService.php b/app/Services/CodeAnalysisService.php index 367b671..153ddc8 100644 --- a/app/Services/CodeAnalysisService.php +++ b/app/Services/CodeAnalysisService.php @@ -8,6 +8,7 @@ use Symfony\Component\Process\Process; class CodeAnalysisService { + public const TOOL_GEMINI = 'gemini'; public const TOOL_CLAUDE = 'claude'; public const TOOL_CODEX = 'codex'; @@ -22,11 +23,10 @@ class CodeAnalysisService * 使用配置的工具在项目中分析日志问题 * * @param string $appName 应用名称 - * @param string $logsContent 日志内容 - * @param string|null $aiSummary AI 初步分析摘要 + * @param array $aiAnalysisResult AI 分析结果(包含 summary, impact, core_anomalies 等) * @return array 分析结果 */ - public function analyze(string $appName, string $logsContent, ?string $aiSummary): array + public function analyze(string $appName, array $aiAnalysisResult): array { $repoPath = $this->codeContextService->getRepoPath($appName); @@ -40,7 +40,7 @@ class CodeAnalysisService $tool = $this->getConfiguredTool(); try { - $prompt = $this->buildPrompt($logsContent, $aiSummary); + $prompt = $this->buildPrompt($aiAnalysisResult); $output = $this->runTool($tool, $repoPath, $prompt); return [ @@ -83,10 +83,10 @@ class CodeAnalysisService */ public function getConfiguredTool(): string { - $tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_CLAUDE); + $tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_GEMINI); - if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) { - return self::TOOL_CLAUDE; + if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) { + return self::TOOL_GEMINI; } return $tool; @@ -97,14 +97,14 @@ class CodeAnalysisService */ public function setTool(string $tool): void { - if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) { + if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) { throw new \InvalidArgumentException("不支持的工具: {$tool}"); } $this->configService->set( 'log_analysis.code_analysis_tool', $tool, - '代码分析工具 (claude/codex)' + '代码分析工具 (gemini/claude/codex)' ); } @@ -114,6 +114,10 @@ class CodeAnalysisService public function getAvailableTools(): array { return [ + self::TOOL_GEMINI => [ + 'name' => 'Gemini CLI', + 'description' => 'Google Gemini 命令行工具', + ], self::TOOL_CLAUDE => [ 'name' => 'Claude CLI', 'description' => 'Anthropic Claude 命令行工具', @@ -126,21 +130,52 @@ class CodeAnalysisService } /** - * 构建提示词 + * 构建提示词(基于 AI 分析汇总结果) */ - private function buildPrompt(string $logsContent, ?string $aiSummary): string + private function buildPrompt(array $aiAnalysisResult): string { - $prompt = "分析以下错误日志,在代码库中排查根本原因并给出具体优化方案:\n\n"; - $prompt .= "=== 日志内容 ===\n{$logsContent}\n\n"; + $prompt = "根据以下日志分析结果,在代码库中排查根本原因并给出具体优化方案:\n\n"; - if ($aiSummary) { - $prompt .= "=== AI 初步分析 ===\n{$aiSummary}\n\n"; + // 影响级别 + $impact = $aiAnalysisResult['impact'] ?? 'unknown'; + $prompt .= "=== 影响级别 ===\n{$impact}\n\n"; + + // AI 摘要 + $summary = $aiAnalysisResult['summary'] ?? ''; + if ($summary) { + $prompt .= "=== 问题摘要 ===\n{$summary}\n\n"; + } + + // 核心异常列表 + $anomalies = $aiAnalysisResult['core_anomalies'] ?? []; + if (!empty($anomalies)) { + $prompt .= "=== 异常列表 ===\n"; + foreach ($anomalies as $idx => $anomaly) { + $num = $idx + 1; + $type = $anomaly['type'] ?? 'unknown'; + $classification = $anomaly['classification'] ?? ''; + $count = $anomaly['count'] ?? 1; + $cause = $anomaly['possible_cause'] ?? ''; + $sample = $anomaly['sample'] ?? ''; + + $prompt .= "{$num}. [{$type}] {$classification} (出现 {$count} 次)\n"; + if ($cause) { + $prompt .= " 可能原因: {$cause}\n"; + } + if ($sample) { + // 限制样本长度,避免过长 + $sampleTruncated = mb_strlen($sample) > 500 ? mb_substr($sample, 0, 500) . '...' : $sample; + $prompt .= " 日志样本: {$sampleTruncated}\n"; + } + } + $prompt .= "\n"; } $prompt .= "请:\n"; $prompt .= "1. 定位相关代码文件\n"; $prompt .= "2. 分析根本原因\n"; $prompt .= "3. 给出具体修复方案\n"; + $prompt .= "\n注意:仅进行分析和提供建议,不要修改任何代码文件。\n"; return $prompt; } @@ -151,24 +186,60 @@ class CodeAnalysisService private function runTool(string $tool, string $workingDirectory, string $prompt): string { return match ($tool) { + self::TOOL_CLAUDE => $this->runClaude($workingDirectory, $prompt), self::TOOL_CODEX => $this->runCodex($workingDirectory, $prompt), - default => $this->runClaude($workingDirectory, $prompt), + default => $this->runGemini($workingDirectory, $prompt), }; } + /** + * 执行 Gemini CLI 命令 + */ + private function runGemini(string $workingDirectory, string $prompt): string + { + $process = new Process( + ['gemini', '--approval-mode', 'plan', '-o', 'json', $prompt], + $workingDirectory, + $this->getEnvWithPath() + ); + $process->setTimeout($this->timeout); + $process->mustRun(); + + $output = trim($process->getOutput()); + + // 解析 JSON 格式输出,提取完整的分析结果 + $json = json_decode($output, true); + if ($json && isset($json['response'])) { + return $json['response']; + } + + // 如果解析失败,返回原始输出 + return $output; + } + /** * 执行 Claude CLI 命令 */ private function runClaude(string $workingDirectory, string $prompt): string { $process = new Process( - ['claude', '--print', $prompt], - $workingDirectory + ['claude', '--print', '--output-format', 'json', $prompt], + $workingDirectory, + $this->getEnvWithPath() ); $process->setTimeout($this->timeout); $process->mustRun(); - return trim($process->getOutput()); + $output = trim($process->getOutput()); + + // 解析 JSON 格式输出,提取完整的分析结果 + $json = json_decode($output, true); + if ($json && isset($json['result'])) { + return $json['result']; + } + + // 如果解析失败,返回原始输出 + return $output; } /** @@ -176,16 +247,100 @@ class CodeAnalysisService */ private function runCodex(string $workingDirectory, string $prompt): string { + // 使用临时文件保存最终消息,避免输出被截断 + $outputFile = sys_get_temp_dir() . '/codex_output_' . uniqid() . '.txt'; + $process = new Process( - ['codex', '--quiet', '--full-auto', $prompt], - $workingDirectory + ['codex', 'exec', '--sandbox', 'read-only', '-o', $outputFile, $prompt], + $workingDirectory, + $this->getEnvWithPath() ); $process->setTimeout($this->timeout); $process->mustRun(); + // 从输出文件读取完整结果 + if (file_exists($outputFile)) { + $output = trim(file_get_contents($outputFile)); + @unlink($outputFile); + return $output; + } + + // 如果文件不存在,回退到标准输出 return trim($process->getOutput()); } + /** + * 获取包含用户 PATH 的环境变量 + * 确保 nvm、npm 全局安装的命令可以被找到 + */ + private function getEnvWithPath(): array + { + $env = getenv(); + $homeDir = getenv('HOME') ?: '/home/' . get_current_user(); + + // 添加常见的用户级 bin 目录到 PATH + $additionalPaths = [ + "{$homeDir}/.local/bin", + "{$homeDir}/.npm-global/bin", + '/usr/local/bin', + ]; + + // 查找 nvm 当前使用的 Node.js 版本的 bin 目录 + $nvmDir = "{$homeDir}/.nvm/versions/node"; + if (is_dir($nvmDir)) { + // 获取最新版本的 Node.js(按版本号排序) + $versions = @scandir($nvmDir); + if ($versions) { + $versions = array_filter($versions, fn($v) => $v !== '.' && $v !== '..'); + if (!empty($versions)) { + usort($versions, 'version_compare'); + $latestVersion = end($versions); + $additionalPaths[] = "{$nvmDir}/{$latestVersion}/bin"; + } + } + } + + $currentPath = $env['PATH'] ?? '/usr/bin:/bin'; + $env['PATH'] = implode(':', $additionalPaths) . ':' . $currentPath; + + // 添加 Gemini API Key(用于非交互式模式) + $geminiApiKey = $this->configService->get('log_analysis.gemini_api_key') ?: config('services.gemini.api_key'); + if ($geminiApiKey) { + $env['GEMINI_API_KEY'] = $geminiApiKey; + } + + // 确保代理环境变量被传递(后台任务可能没有继承) + $proxyUrl = config('services.proxy.url'); + if ($proxyUrl) { + $env['HTTP_PROXY'] = $proxyUrl; + $env['HTTPS_PROXY'] = $proxyUrl; + $env['http_proxy'] = $proxyUrl; + $env['https_proxy'] = $proxyUrl; + } + + return $env; + } + + /** + * 设置 Gemini API Key + */ + public function setGeminiApiKey(string $apiKey): void + { + $this->configService->set( + 'log_analysis.gemini_api_key', + $apiKey, + 'Gemini CLI API Key (用于非交互式模式)' + ); + } + + /** + * 获取 Gemini API Key + */ + public function getGeminiApiKey(): ?string + { + return $this->configService->get('log_analysis.gemini_api_key') ?: config('services.gemini.api_key'); + } + /** * 设置超时时间 */ diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index 5d499d9..7c635ff 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -132,10 +132,19 @@ class JiraService throw new \InvalidArgumentException('用户名不能为空'); } + // 定义已完成的状态列表(需要排除的状态) + $completedStatuses = [ + 'Done', + '已上线', + '已完成', + ]; + $statusExclusion = implode('", "', $completedStatuses); + // 查询分配给用户且未完成的需求(Story/需求类型,不包括子任务) $jql = sprintf( - 'assignee = "%s" AND status != "Done" AND issuetype in ("Story", "需求") ORDER BY created ASC', - $username + 'assignee = "%s" AND status NOT IN ("%s") AND issuetype in ("Story", "需求") ORDER BY created ASC', + $username, + $statusExclusion ); try { @@ -203,6 +212,25 @@ class JiraService } } + // 没有Sprint的需求 + if ($organizedTasks->has('stories') && $organizedTasks['stories']->isNotEmpty()) { + $markdown .= "### 需求\n"; + foreach ($organizedTasks['stories'] as $task) { + $checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]'; + $markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n"; + + if ($task['subtasks']->isNotEmpty()) { + // 按创建时间排序子任务 + $sortedSubtasks = $task['subtasks']->sortBy('created'); + foreach ($sortedSubtasks as $subtask) { + $subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]'; + $markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n"; + } + } + } + $markdown .= "\n"; + } + // 单独列出的任务 if ($organizedTasks->has('tasks') && $organizedTasks['tasks']->isNotEmpty()) { $markdown .= "### 任务\n"; @@ -675,9 +703,10 @@ class JiraService */ private function isTaskCompleted(string $status): bool { - // 定义"进行中"的状态列表 + // 定义"未完成"的状态列表(包括未开始和进行中) // 如果状态不在这个列表中,则认为任务已完成 - $inProgressStatuses = [ + $incompleteStatuses = [ + '未开始', '需求已确认', '开发中', '需求调研中', @@ -688,8 +717,8 @@ class JiraService '需求设计中', ]; - // 如果状态不在"进行中"列表中,则标记为已完成 - return !in_array($status, $inProgressStatuses, true); + // 如果状态不在"未完成"列表中,则标记为已完成 + return !in_array($status, $incompleteStatuses, true); } /** @@ -699,6 +728,7 @@ class JiraService { $organized = collect([ 'sprints' => collect(), + 'stories' => collect(), // 没有Sprint的需求 'tasks' => collect(), 'bugs' => collect(), ]); @@ -779,8 +809,25 @@ class JiraService } $this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog); + } elseif ($isStory && !$workLog['sprint']) { + // Story类型但没有Sprint的,放入需求分类 + $this->addTaskToSprintOrTaskList($organized['stories'], $workLog); + } elseif ($isSubtask && !$workLog['sprint'] && $workLog['parent_task']) { + // 子任务没有Sprint,检查父任务类型来决定分类 + $parentKey = $workLog['parent_task']['key']; + $parentDetails = $this->getIssueDetails($parentKey); + $parentType = $parentDetails ? ($parentDetails->fields->issuetype->name ?? '') : ''; + $isParentStory = in_array($parentType, ['Story', 'story', '需求']); + + if ($isParentStory) { + // 父任务是Story,放入需求分类 + $this->addTaskToSprintOrTaskList($organized['stories'], $workLog); + } else { + // 父任务不是Story,放入任务分类 + $this->addTaskToSprintOrTaskList($organized['tasks'], $workLog); + } } else { - // 其他任务单独列出(非Story/子任务类型或没有Sprint的) + // 其他任务单独列出(非Story/子任务类型) $this->addTaskToSprintOrTaskList($organized['tasks'], $workLog); } } diff --git a/app/Services/LogAnalysisService.php b/app/Services/LogAnalysisService.php index 72d617f..0dc03bd 100644 --- a/app/Services/LogAnalysisService.php +++ b/app/Services/LogAnalysisService.php @@ -34,10 +34,10 @@ class LogAnalysisService AnalysisMode $mode = AnalysisMode::Logs, bool $pushNotification = false ): LogAnalysisReport { - // 如果没有指定查询条件,默认只获取 ERROR 和 WARNING 级别的日志 + // 如果没有指定查询条件,默认只获取 ERROR 级别的日志 $effectiveQuery = $query; if (empty($query)) { - $effectiveQuery = 'ERROR or WARNING'; + $effectiveQuery = 'content.level: ERROR'; } // 创建 pending 状态的报告 @@ -114,8 +114,7 @@ class LogAnalysisService if (in_array($impact, ['high', 'medium'])) { $codeAnalysisResult = $this->codeAnalysisService->analyze( $appName, - $logsContent, - $results[$appName]['summary'] ?? null + $results[$appName] ); $results[$appName]['code_analysis'] = $codeAnalysisResult; } diff --git a/app/Services/MessageSyncService.php b/app/Services/MessageSyncService.php index 62994a8..641d355 100644 --- a/app/Services/MessageSyncService.php +++ b/app/Services/MessageSyncService.php @@ -2,18 +2,18 @@ namespace App\Services; -use App\Clients\AgentClient; +use App\Clients\MonoClient; use Illuminate\Support\Facades\DB; use Illuminate\Support\Collection; use Carbon\Carbon; class MessageSyncService { - private AgentClient $agentClient; + private MonoClient $monoClient; - public function __construct(AgentClient $agentClient) + public function __construct(MonoClient $monoClient) { - $this->agentClient = $agentClient; + $this->monoClient = $monoClient; } /** @@ -57,80 +57,51 @@ class MessageSyncService } /** - * 批量同步消息到agent + * 批量同步消息(通过mono消费) */ public function syncMessages(array $messageIds): array { - $messages = $this->getMessagesByIds($messageIds); $results = []; - foreach ($messages as $message) { - $result = $this->syncSingleMessage($message); - $results[] = [ - 'msg_id' => $message['msg_id'], - 'success' => $result['success'], - 'response' => $result['response'] ?? null, - 'error' => $result['error'] ?? null, - 'request_data' => $result['request_data'] ?? null, - ]; + foreach ($messageIds as $msgId) { + $results[] = $this->syncSingleMessage($msgId); } return $results; } /** - * 同步单个消息到agent + * 通过mono消费单个消息 */ - private function syncSingleMessage(array $message): array + private function syncSingleMessage(string $msgId): array { try { - $requestData = $this->buildAgentRequest($message); + $response = $this->monoClient->consumeMessage($msgId); + $body = $response->json(); - $response = $this->agentClient->dispatchMessage($requestData); - - if ($response->successful()) { + if ($response->successful() && ($body['code'] ?? -1) === 0) { return [ + 'msg_id' => $msgId, 'success' => true, - 'response' => $response->json(), - 'request_data' => $requestData, + 'response' => $body, ]; } else { return [ + 'msg_id' => $msgId, 'success' => false, - 'error' => 'HTTP ' . $response->status() . ': ' . $response->body(), - 'request_data' => $requestData, + 'error' => $body['message'] ?? ('HTTP ' . $response->status() . ': ' . $response->body()), + 'response' => $body, ]; } } catch (\Exception $e) { return [ + 'msg_id' => $msgId, 'success' => false, 'error' => '请求失败: ' . $e->getMessage(), - 'request_data' => $requestData ?? null, ]; } } - /** - * 构建agent接口请求数据 - */ - private function buildAgentRequest(array $message): array - { - $parsedParam = $message['parsed_param']; - $parsedProperty = $message['parsed_property']; - - return [ - 'topic_name' => $message['event_type'], - 'msg_body' => [ - 'id' => $message['msg_id'], - 'data' => $parsedParam, - 'timestamp' => $message['timestamp'], - 'property' => $parsedProperty, - ], - 'target_service' => [1], // 默认目标服务 - 'trace_id' => $message['trace_id'], - ]; - } - /** * 解析JSON字段 */ diff --git a/composer.json b/composer.json index 271458c..ef0d378 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "lesstif/php-jira-rest-client": "5.10.0" }, "require-dev": { + "cweagans/composer-patches": "*", "fakerphp/faker": "^1.23", "laravel/pail": "^1.2.2", "laravel/pint": "^1.13", @@ -61,6 +62,11 @@ "extra": { "laravel": { "dont-discover": [] + }, + "patches": { + "alibabacloud/aliyun-log-php-sdk": { + "Fix PHP 8.x CurlHandle cannot be converted to string": "patches/aliyun-log-php-sdk-php8-fix.patch" + } } }, "config": { @@ -69,7 +75,8 @@ "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, - "php-http/discovery": true + "php-http/discovery": true, + "cweagans/composer-patches": true } }, "minimum-stability": "stable", diff --git a/composer.lock b/composer.lock index 259a306..f72b5dd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c0be44d46402c6a66259be9824335576", + "content-hash": "e66630836dd52f91ae3b422e8187ed3c", "packages": [ { "name": "alibabacloud/aliyun-log-php-sdk", @@ -5952,6 +5952,129 @@ } ], "packages-dev": [ + { + "name": "cweagans/composer-configurable-plugin", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/cweagans/composer-configurable-plugin.git", + "reference": "15433906511a108a1806710e988629fd24b89974" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweagans/composer-configurable-plugin/zipball/15433906511a108a1806710e988629fd24b89974", + "reference": "15433906511a108a1806710e988629fd24b89974", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "require-dev": { + "codeception/codeception": "~4.0", + "codeception/module-asserts": "^2.0", + "composer/composer": "~2.0", + "php-coveralls/php-coveralls": "~2.0", + "php-parallel-lint/php-parallel-lint": "^1.0.0", + "phpro/grumphp": "^1.8.0", + "sebastian/phpcpd": "^6.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "cweagans\\Composer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Cameron Eagans", + "email": "me@cweagans.net" + } + ], + "description": "Provides a lightweight configuration system for Composer plugins.", + "support": { + "issues": "https://github.com/cweagans/composer-configurable-plugin/issues", + "source": "https://github.com/cweagans/composer-configurable-plugin/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/cweagans", + "type": "github" + } + ], + "time": "2023-02-12T04:58:58+00:00" + }, + { + "name": "cweagans/composer-patches", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/cweagans/composer-patches.git", + "reference": "bfa6018a5f864653d9ed899b902ea72f858a2cf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/bfa6018a5f864653d9ed899b902ea72f858a2cf7", + "reference": "bfa6018a5f864653d9ed899b902ea72f858a2cf7", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "cweagans/composer-configurable-plugin": "^2.0", + "ext-json": "*", + "php": ">=8.0.0" + }, + "require-dev": { + "codeception/codeception": "~4.0", + "codeception/module-asserts": "^2.0", + "codeception/module-cli": "^2.0", + "codeception/module-filesystem": "^2.0", + "composer/composer": "~2.0", + "php-coveralls/php-coveralls": "~2.0", + "php-parallel-lint/php-parallel-lint": "^1.0.0", + "phpro/grumphp": "^1.8.0", + "sebastian/phpcpd": "^6.0", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "composer-plugin", + "extra": { + "_": "The following two lines ensure that composer-patches is loaded as early as possible.", + "class": "cweagans\\Composer\\Plugin\\Patches", + "plugin-modifies-downloads": true, + "plugin-modifies-install-path": true + }, + "autoload": { + "psr-4": { + "cweagans\\Composer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Cameron Eagans", + "email": "me@cweagans.net" + } + ], + "description": "Provides a way to patch Composer packages.", + "support": { + "issues": "https://github.com/cweagans/composer-patches/issues", + "source": "https://github.com/cweagans/composer-patches/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/cweagans", + "type": "github" + } + ], + "time": "2025-10-30T23:44:22+00:00" + }, { "name": "fakerphp/faker", "version": "v1.24.1", diff --git a/config/services.php b/config/services.php index 60083a7..6635aaa 100644 --- a/config/services.php +++ b/config/services.php @@ -69,4 +69,12 @@ return [ 'max_tokens' => (int) env('AI_MAX_TOKENS', 4096), ], + 'gemini' => [ + 'api_key' => env('GEMINI_API_KEY'), + ], + + 'proxy' => [ + 'url' => env('PROXY_URL'), + ], + ]; diff --git a/patches.lock.json b/patches.lock.json new file mode 100644 index 0000000..7d22e26 --- /dev/null +++ b/patches.lock.json @@ -0,0 +1,17 @@ +{ + "_hash": "9bce1dd342959a98713ba4689644c1aace66eb0fbe029720f51715c3f5841ba0", + "patches": { + "alibabacloud/aliyun-log-php-sdk": [ + { + "package": "alibabacloud/aliyun-log-php-sdk", + "description": "Fix PHP 8.x CurlHandle cannot be converted to string", + "url": "patches/aliyun-log-php-sdk-php8-fix.patch", + "sha256": "45572f8024eb66fd70902e03deb5c5ee90a735d6dcec180bf7264a4b2e7183af", + "depth": 1, + "extra": { + "provenance": "root" + } + } + ] + } +} diff --git a/patches/aliyun-log-php-sdk-php8-fix.patch b/patches/aliyun-log-php-sdk-php8-fix.patch new file mode 100644 index 0000000..5d50b94 --- /dev/null +++ b/patches/aliyun-log-php-sdk-php8-fix.patch @@ -0,0 +1,20 @@ +--- a/Aliyun/Log/requestcore.class.php ++++ b/Aliyun/Log/requestcore.class.php +@@ -832,7 +832,7 @@ + + if ($this->response === false) + { +- throw new RequestCore_Exception('cURL resource: ' . (string) $curl_handle . '; cURL error: ' . curl_error($curl_handle) . ' (' . curl_errno($curl_handle) . ')'); ++ throw new RequestCore_Exception('cURL error: ' . curl_error($curl_handle) . ' (' . curl_errno($curl_handle) . ')'); + } + + $parsed_response = $this->process_response($curl_handle, $this->response); +@@ -905,7 +905,7 @@ + // Since curl_errno() isn't reliable for handles that were in multirequests, we check the 'result' of the info read, which contains the curl error number, (listed here http://curl.haxx.se/libcurl/c/libcurl-errors.html ) + if ($done['result'] > 0) + { +- throw new RequestCore_Exception('cURL resource: ' . (string) $done['handle'] . '; cURL error: ' . curl_error($done['handle']) . ' (' . $done['result'] . ')'); ++ throw new RequestCore_Exception('cURL error: ' . curl_error($done['handle']) . ' (' . $done['result'] . ')'); + } + + // Because curl_multi_info_read() might return more than one message about a request, we check to see if this request is already in our array of completed requests diff --git a/resources/js/components/message-sync/MessageSync.vue b/resources/js/components/message-sync/MessageSync.vue index 2569374..fee7c60 100644 --- a/resources/js/components/message-sync/MessageSync.vue +++ b/resources/js/components/message-sync/MessageSync.vue @@ -1,133 +1,106 @@