#feature: add AI log analysis & some bugfix
This commit is contained in:
275
app/Clients/AiClient.php
Normal file
275
app/Clients/AiClient.php
Normal 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
292
app/Clients/SlsClient.php
Normal 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_* 配置项');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user