#feature: add AI log analysis & some bugfix

This commit is contained in:
2026-01-14 13:58:50 +08:00
parent e479ed02ea
commit ae6c169f5f
33 changed files with 3898 additions and 164 deletions

275
app/Clients/AiClient.php Normal file
View File

@@ -0,0 +1,275 @@
<?php
namespace App\Clients;
use App\Services\ConfigService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use RuntimeException;
class AiClient
{
private ConfigService $configService;
private ?array $currentProvider = null;
public function __construct(ConfigService $configService)
{
$this->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);
}
}

292
app/Clients/SlsClient.php Normal file
View File

@@ -0,0 +1,292 @@
<?php
namespace App\Clients;
use Aliyun_Log_Client;
use Aliyun_Log_Models_GetLogsRequest;
use Aliyun_Log_Models_GetHistogramsRequest;
use Aliyun_Log_Exception;
use RuntimeException;
class SlsClient
{
private ?Aliyun_Log_Client $client = null;
private string $project;
private array $logstores;
public function __construct()
{
$config = config('services.sls');
if (empty($config['endpoint']) || empty($config['access_key_id']) || empty($config['access_key_secret'])) {
return;
}
$this->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_* 配置项');
}
}
}

View File

@@ -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));
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Console\Commands;
use App\Enums\AnalysisMode;
use App\Services\LogAnalysisService;
use App\Services\SlsService;
use App\Services\AiService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class LogAnalysisCommand extends Command
{
protected $signature = 'log-analysis:run
{--from= : 开始时间 (Y-m-d H:i:s 或相对时间如 -1h, -30m, -1d)}
{--to= : 结束时间 (Y-m-d H:i:s 或相对时间,默认 now)}
{--query= : SLS 查询语句}
{--mode=logs : 分析模式 (logs|logs+code)}
{--output= : 输出 JSON 报告的文件路径}
{--push : 推送结果到钉钉}
{--no-save : 不保存报告到数据库}';
protected $description = '分析 SLS 日志,支持 AI 智能分析和钉钉推送';
public function handle(
LogAnalysisService $analysisService,
SlsService $slsService,
AiService $aiService
): int {
// 检查配置
if (!$slsService->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(" 影响级别: <fg={$impactColor}>{$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();
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum AnalysisMode: string
{
case Logs = 'logs';
case LogsWithCode = 'logs+code';
public function label(): string
{
return match ($this) {
self::Logs => '仅日志分析',
self::LogsWithCode => '日志 + 代码分析',
};
}
}

View File

@@ -0,0 +1,315 @@
<?php
namespace App\Http\Controllers;
use App\Enums\AnalysisMode;
use App\Services\AiService;
use App\Services\ConfigService;
use App\Services\LogAnalysisService;
use App\Services\SlsService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LogAnalysisController extends Controller
{
public function __construct(
private readonly LogAnalysisService $analysisService,
private readonly SlsService $slsService,
private readonly AiService $aiService,
private readonly ConfigService $configService
) {}
/**
* 查询日志(预览,不进行 AI 分析)
*/
public function queryLogs(Request $request): JsonResponse
{
$validated = $request->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,
]);
}
}

224
app/Jobs/LogAnalysisJob.php Normal file
View File

@@ -0,0 +1,224 @@
<?php
namespace App\Jobs;
use App\Enums\AnalysisMode;
use App\Models\LogAnalysisReport;
use App\Services\AiService;
use App\Services\CodeContextService;
use App\Services\ConfigService;
use App\Services\DingTalkService;
use App\Services\SlsService;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class LogAnalysisJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 600; // 10 分钟超时
public int $tries = 1; // 只尝试一次
public function __construct(
private readonly int $reportId,
private readonly Carbon $from,
private readonly Carbon $to,
private readonly ?string $query,
private readonly AnalysisMode $mode,
private readonly bool $pushNotification = false
) {}
public function handle(
SlsService $slsService,
AiService $aiService,
CodeContextService $codeContextService,
ConfigService $configService,
DingTalkService $dingTalkService
): void {
$report = LogAnalysisReport::find($this->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(),
]);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
class LogAnalysisReport extends BaseModel
{
protected $fillable = [
'from_time',
'to_time',
'query',
'mode',
'total_logs',
'results',
'metadata',
'status',
'error_message',
];
protected $casts = [
'from_time' => 'datetime',
'to_time' => 'datetime',
'results' => 'array',
'metadata' => 'array',
];
}

View File

@@ -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);
}
/**

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Providers;
use App\Clients\AgentClient;
use App\Clients\MonoClient;
use Illuminate\Support\ServiceProvider;
class ClientServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->singleton(AgentClient::class, function () {
return new AgentClient();
});
$this->app->singleton(MonoClient::class, function () {
return new MonoClient();
});
}
/**
* Bootstrap services.
*/
public function boot(): void
{
//
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Console\Commands\EnvCommand;
class EnvServiceProvider extends ServiceProvider
{
/**
* 注册服务
*/
public function register(): void
{
// 注册环境管理服务
$this->app->singleton(\App\Services\EnvService::class);
}
/**
* 启动服务
*/
public function boot(): void
{
// 注册Artisan命令
if ($this->app->runningInConsole()) {
$this->commands([
EnvCommand::class,
]);
}
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Providers;
use App\Console\Commands\GitMonitorCacheCommand;
use App\Console\Commands\GitMonitorCheckCommand;
use Illuminate\Support\ServiceProvider;
class GitMonitorServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
GitMonitorCheckCommand::class,
GitMonitorCacheCommand::class,
]);
}
}
}

126
app/Services/AiService.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
namespace App\Services;
use App\Clients\AiClient;
class AiService
{
public function __construct(
private readonly AiClient $client,
private readonly ConfigService $configService
) {}
/**
* 检查 AI 服务是否已配置
*/
public function isConfigured(): bool
{
return $this->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();
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
class CodeContextService
{
private int $contextLines = 10;
public function __construct(
private readonly ConfigService $configService,
private readonly EnvService $envService
) {}
/**
* 根据 app_name 获取代码仓库路径
*
* @param string $appName
* @return string|null
*/
public function getRepoPath(string $appName): ?string
{
$appEnvMap = $this->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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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,
];
}
}

View File

@@ -0,0 +1,437 @@
<?php
namespace App\Services;
use App\Enums\AnalysisMode;
use App\Jobs\LogAnalysisJob;
use App\Models\LogAnalysisReport;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class LogAnalysisService
{
public function __construct(
private readonly SlsService $slsService,
private readonly AiService $aiService,
private readonly CodeContextService $codeContextService,
private readonly ConfigService $configService,
private readonly DingTalkService $dingTalkService
) {}
/**
* 异步执行日志分析(创建后台任务)
*
* @param Carbon $from 开始时间
* @param Carbon $to 结束时间
* @param string|null $query SLS 查询语句
* @param AnalysisMode $mode 分析模式
* @param bool $pushNotification 是否推送通知
* @return LogAnalysisReport 创建的报告(状态为 pending
*/
public function analyzeAsync(
Carbon $from,
Carbon $to,
?string $query = null,
AnalysisMode $mode = AnalysisMode::Logs,
bool $pushNotification = false
): LogAnalysisReport {
// 如果没有指定查询条件,默认只获取 ERROR 和 WARNING 级别的日志
$effectiveQuery = $query;
if (empty($query)) {
$effectiveQuery = 'ERROR or WARNING';
}
// 创建 pending 状态的报告
$report = LogAnalysisReport::create([
'from_time' => $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' => '未找到匹配的日志',
],
];
}
}

288
app/Services/SlsService.php Normal file
View File

@@ -0,0 +1,288 @@
<?php
namespace App\Services;
use App\Clients\SlsClient;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class SlsService
{
public function __construct(
private readonly SlsClient $client,
private readonly ConfigService $configService
) {}
/**
* 检查 SLS 是否已配置
*/
public function isConfigured(): bool
{
return $this->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<string, Collection>
*/
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;
}
}