352 lines
11 KiB
PHP
352 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class CodeAnalysisService
|
|
{
|
|
public const TOOL_GEMINI = 'gemini';
|
|
public const TOOL_CLAUDE = 'claude';
|
|
public const TOOL_CODEX = 'codex';
|
|
|
|
private int $timeout = 300;
|
|
|
|
public function __construct(
|
|
private readonly CodeContextService $codeContextService,
|
|
private readonly ConfigService $configService
|
|
) {}
|
|
|
|
/**
|
|
* 使用配置的工具在项目中分析日志问题
|
|
*
|
|
* @param string $appName 应用名称
|
|
* @param array $aiAnalysisResult AI 分析结果(包含 summary, impact, core_anomalies 等)
|
|
* @return array 分析结果
|
|
*/
|
|
public function analyze(string $appName, array $aiAnalysisResult): array
|
|
{
|
|
$repoPath = $this->codeContextService->getRepoPath($appName);
|
|
|
|
if (!$repoPath) {
|
|
return [
|
|
'success' => false,
|
|
'error' => "未配置项目路径: {$appName}",
|
|
];
|
|
}
|
|
|
|
$tool = $this->getConfiguredTool();
|
|
|
|
try {
|
|
$prompt = $this->buildPrompt($aiAnalysisResult);
|
|
$output = $this->runTool($tool, $repoPath, $prompt);
|
|
|
|
return [
|
|
'success' => true,
|
|
'output' => $output,
|
|
'repo_path' => $repoPath,
|
|
'tool' => $tool,
|
|
];
|
|
} catch (ProcessFailedException $e) {
|
|
Log::error("{$tool} execution failed", [
|
|
'app_name' => $appName,
|
|
'repo_path' => $repoPath,
|
|
'tool' => $tool,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => "{$tool} 执行失败: " . $e->getProcess()->getErrorOutput(),
|
|
'tool' => $tool,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::error("Code analysis failed", [
|
|
'app_name' => $appName,
|
|
'repo_path' => $repoPath,
|
|
'tool' => $tool,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
'tool' => $tool,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取配置的分析工具
|
|
*/
|
|
public function getConfiguredTool(): string
|
|
{
|
|
$tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_GEMINI);
|
|
|
|
if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) {
|
|
return self::TOOL_GEMINI;
|
|
}
|
|
|
|
return $tool;
|
|
}
|
|
|
|
/**
|
|
* 设置分析工具
|
|
*/
|
|
public function setTool(string $tool): void
|
|
{
|
|
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,
|
|
'代码分析工具 (gemini/claude/codex)'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 获取可用的工具列表
|
|
*/
|
|
public function getAvailableTools(): array
|
|
{
|
|
return [
|
|
self::TOOL_GEMINI => [
|
|
'name' => 'Gemini CLI',
|
|
'description' => 'Google Gemini 命令行工具',
|
|
],
|
|
self::TOOL_CLAUDE => [
|
|
'name' => 'Claude CLI',
|
|
'description' => 'Anthropic Claude 命令行工具',
|
|
],
|
|
self::TOOL_CODEX => [
|
|
'name' => 'Codex CLI',
|
|
'description' => 'OpenAI Codex 命令行工具',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 构建提示词(基于 AI 分析汇总结果)
|
|
*/
|
|
private function buildPrompt(array $aiAnalysisResult): string
|
|
{
|
|
$prompt = "根据以下日志分析结果,在代码库中排查根本原因并给出具体优化方案:\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;
|
|
}
|
|
|
|
/**
|
|
* 执行分析工具
|
|
*/
|
|
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->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', '--output-format', '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['result'])) {
|
|
return $json['result'];
|
|
}
|
|
|
|
// 如果解析失败,返回原始输出
|
|
return $output;
|
|
}
|
|
|
|
/**
|
|
* 执行 Codex CLI 命令
|
|
*/
|
|
private function runCodex(string $workingDirectory, string $prompt): string
|
|
{
|
|
// 使用临时文件保存最终消息,避免输出被截断
|
|
$outputFile = sys_get_temp_dir() . '/codex_output_' . uniqid() . '.txt';
|
|
|
|
$process = new Process(
|
|
['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');
|
|
}
|
|
|
|
/**
|
|
* 设置超时时间
|
|
*/
|
|
public function setTimeout(int $timeout): void
|
|
{
|
|
$this->timeout = $timeout;
|
|
}
|
|
}
|