Compare commits

...

1 Commits

Author SHA1 Message Date
53bca7d609 #feature: update AI log analysis 2026-02-11 11:00:32 +08:00
18 changed files with 688 additions and 262 deletions

View File

@@ -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=

88
CLAUDE.md Normal file
View File

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

View File

@@ -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 错误

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');
}
/**
* 设置超时时间
*/

View File

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

View File

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

View File

@@ -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字段
*/

View File

@@ -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",

125
composer.lock generated
View File

@@ -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",

View File

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

17
patches.lock.json Normal file
View File

@@ -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"
}
}
]
}
}

View File

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

View File

@@ -1,133 +1,106 @@
<template>
<div class="p-6">
<div class="p-4">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">消息同步</h1>
<p class="text-gray-600 mt-2">批量输入消息ID从crmslave数据库查询并同步到agent服务</p>
<div class="mb-3 flex items-center justify-between">
<div>
<h1 class="text-lg font-bold text-gray-900">消息同步</h1>
<p class="text-xs text-gray-500 mt-0.5">输入消息ID通过Mono服务重新消费并分发消息</p>
</div>
<button
@click="testConnection"
:disabled="loading.test"
class="px-3 py-1.5 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200 disabled:opacity-50 flex items-center"
>
<svg v-if="loading.test" class="animate-spin -ml-0.5 mr-1.5 h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
测试连接
</button>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded px-3 py-2 mb-3 flex items-start text-sm">
<svg class="w-4 h-4 text-red-400 mr-1.5 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-red-700">{{ error }}</span>
</div>
<!-- 输入区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">消息ID输入</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
消息ID列表 (每行一个ID)
</label>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-3">
<div class="flex gap-4">
<div class="flex-1">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-medium text-gray-600">消息ID每行一个</label>
<span class="text-xs text-gray-400">{{ messageIdsList.length }} </span>
</div>
<textarea
v-model="messageIdsText"
rows="8"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入消息ID每行一个&#10;例如:&#10;af7e5ca7-2779-0e9e-93d1-68c79ceffd9033&#10;bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
rows="6"
class="w-full border border-gray-300 rounded px-2.5 py-1.5 text-sm font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500 resize-none"
placeholder="af7e5ca7-2779-0e9e-93d1-68c79ceffd9033&#10;bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
></textarea>
<div class="text-sm text-gray-500 mt-1">
{{ messageIdsList.length }} 个消息ID
</div>
</div>
<div class="flex space-x-4">
<div class="flex flex-col gap-2 pt-6">
<button
@click="queryMessages"
:disabled="loading.query || messageIdsList.length === 0"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
class="px-4 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center whitespace-nowrap"
>
<svg v-if="loading.query" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<svg v-if="loading.query" class="animate-spin -ml-0.5 mr-1.5 h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
查询消息
</button>
<button
@click="syncMessages"
:disabled="loading.sync || !queryResults || messageIdsList.length === 0"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
:disabled="loading.sync || messageIdsList.length === 0"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center whitespace-nowrap"
>
<svg v-if="loading.sync" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<svg v-if="loading.sync" class="animate-spin -ml-0.5 mr-1.5 h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
执行同步
</button>
<button
@click="testConnection"
:disabled="loading.test"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.test" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
测试连接
</button>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 查询结果 -->
<div v-if="queryResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询结果</h2>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ queryResults.stats.total_requested }}</div>
<div class="text-sm text-blue-600">请求总数</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ queryResults.stats.total_found }}</div>
<div class="text-sm text-green-600">找到记录</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ queryResults.stats.total_missing }}</div>
<div class="text-sm text-red-600">缺失记录</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">{{ Object.keys(queryResults.stats.event_types).length }}</div>
<div class="text-sm text-purple-600">事件类型</div>
<div v-if="queryResults" class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-3">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">查询结果</h2>
<div class="flex gap-4 text-xs">
<span class="text-blue-600">请求 <b>{{ queryResults.stats.total_requested }}</b></span>
<span class="text-green-600">找到 <b>{{ queryResults.stats.total_found }}</b></span>
<span class="text-red-600">缺失 <b>{{ queryResults.stats.total_missing }}</b></span>
<span class="text-purple-600">类型 <b>{{ Object.keys(queryResults.stats.event_types).length }}</b></span>
</div>
</div>
<!-- 消息列表 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">事件类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">跟踪ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500">
<th class="text-left py-2 pr-3 font-medium">消息ID</th>
<th class="text-left py-2 pr-3 font-medium">事件类型</th>
<th class="text-left py-2 pr-3 font-medium">跟踪ID</th>
<th class="text-left py-2 pr-3 font-medium">时间</th>
<th class="text-left py-2 font-medium w-12"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="message in queryResults.messages" :key="message.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ message.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ message.event_type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500">{{ message.trace_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatTimestamp(message.timestamp) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="showMessageDetail(message)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
<tbody class="divide-y divide-gray-100">
<tr v-for="message in queryResults.messages" :key="message.msg_id" class="hover:bg-gray-50">
<td class="py-1.5 pr-3 font-mono text-xs text-gray-900">{{ message.msg_id }}</td>
<td class="py-1.5 pr-3 text-gray-700">{{ message.event_type }}</td>
<td class="py-1.5 pr-3 font-mono text-xs text-gray-400">{{ message.trace_id }}</td>
<td class="py-1.5 pr-3 text-xs text-gray-500 whitespace-nowrap">{{ formatTimestamp(message.timestamp) }}</td>
<td class="py-1.5">
<button @click="showMessageDetail(message)" class="text-blue-500 hover:text-blue-700 text-xs">详情</button>
</td>
</tr>
</tbody>
@@ -136,57 +109,39 @@
</div>
<!-- 同步结果 -->
<div v-if="syncResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">同步结果</h2>
<!-- 同步统计 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ syncResults.summary.total }}</div>
<div class="text-sm text-blue-600">总计</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ syncResults.summary.success }}</div>
<div class="text-sm text-green-600">成功</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ syncResults.summary.failure }}</div>
<div class="text-sm text-red-600">失败</div>
<div v-if="syncResults" class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">同步结果</h2>
<div class="flex gap-4 text-xs">
<span class="text-blue-600">总计 <b>{{ syncResults.summary.total }}</b></span>
<span class="text-green-600">成功 <b>{{ syncResults.summary.success }}</b></span>
<span class="text-red-600">失败 <b>{{ syncResults.summary.failure }}</b></span>
</div>
</div>
<!-- 同步结果列表 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">响应</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
<table class="min-w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500">
<th class="text-left py-2 pr-3 font-medium">消息ID</th>
<th class="text-left py-2 pr-3 font-medium w-16">状态</th>
<th class="text-left py-2 pr-3 font-medium">响应</th>
<th class="text-left py-2 font-medium w-12"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="result in syncResults.results" :key="result.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ result.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="result.success" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
成功
</span>
<span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
失败
</span>
<tbody class="divide-y divide-gray-100">
<tr v-for="result in syncResults.results" :key="result.msg_id" class="hover:bg-gray-50">
<td class="py-1.5 pr-3 font-mono text-xs text-gray-900">{{ result.msg_id }}</td>
<td class="py-1.5 pr-3">
<span v-if="result.success" class="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">成功</span>
<span v-else class="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">失败</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{{ result.success ? '同步成功' : result.error }}
<td class="py-1.5 pr-3 text-xs text-gray-500 max-w-md truncate">
{{ result.success ? '消息消费成功' : result.error }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="showSyncDetail(result)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
<td class="py-1.5">
<button @click="showSyncDetail(result)" class="text-blue-500 hover:text-blue-700 text-xs">详情</button>
</td>
</tr>
</tbody>
@@ -195,20 +150,18 @@
</div>
<!-- 详情模态框 -->
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">详细信息</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<pre class="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">{{ JSON.stringify(selectedDetail, null, 2) }}</pre>
</div>
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50" @click.self="closeDetailModal">
<div class="relative top-16 mx-auto p-4 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-lg bg-white">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold text-gray-900">详细信息</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<pre class="bg-gray-50 p-3 rounded text-xs font-mono overflow-x-auto leading-relaxed">{{ JSON.stringify(selectedDetail, null, 2) }}</pre>
</div>
</div>
</div>

View File

@@ -23,7 +23,7 @@ Artisan::command('inspire', function () {
// Git Monitor - 每 10 分钟检查 release 分支
Schedule::command('git-monitor:check')
->everyTenMinutes()
->withoutOverlapping()
->withoutOverlapping(10)
->runInBackground()
->description('git-monitor-check')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check'));
@@ -35,8 +35,8 @@ Schedule::command('git-monitor:cache')
->description('git-monitor-cache')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-cache'));
// SLS 日志分析 - 每天凌晨 2 点执行
Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or WARNING" --push')
// SLS 日志分析 - 每天凌晨 2 点执行(日志分析 + 代码分析)
Schedule::command('log-analysis:run --from="-24h" --to="now" --query="content.level: ERROR" --mode=logs+code --push')
->dailyAt('02:00')
->withoutOverlapping()
->runInBackground()
@@ -45,9 +45,9 @@ Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or W
->onFailure(fn() => Log::error('每日日志分析定时任务执行失败'));
// SLS 日志分析 - 每 4 小时执行一次
Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WARNING" --push')
Schedule::command('log-analysis:run --from="-6h" --to="now" --query="content.level: ERROR" --push')
->everyFourHours()
->withoutOverlapping()
->withoutOverlapping(60)
->runInBackground()
->description('frequent-log-analysis')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis'))
@@ -56,7 +56,7 @@ Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WA
// Jenkins Monitor - 每分钟检查新构建
Schedule::command('jenkins:monitor')
->everyMinute()
->withoutOverlapping()
->withoutOverlapping(10)
->runInBackground()
->description('jenkins-monitor')
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('jenkins-monitor'));