Compare commits
4 Commits
381d5e6e49
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53bca7d609 | |||
| ddd0f531fd | |||
| 0646c8612b | |||
| da3b05b7c0 |
16
.env.example
16
.env.example
@@ -126,3 +126,19 @@ 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=
|
||||
|
||||
# Jenkins Configuration
|
||||
JENKINS_HOST=http://jenkins.example.com
|
||||
JENKINS_USERNAME=
|
||||
JENKINS_API_TOKEN=
|
||||
JENKINS_TIMEOUT=30
|
||||
|
||||
|
||||
88
CLAUDE.md
Normal file
88
CLAUDE.md
Normal 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
|
||||
@@ -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 错误
|
||||
|
||||
97
app/Clients/JenkinsClient.php
Normal file
97
app/Clients/JenkinsClient.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clients;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JenkinsClient
|
||||
{
|
||||
private ?string $host;
|
||||
private ?string $username;
|
||||
private ?string $apiToken;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('jenkins', []);
|
||||
$this->host = rtrim($config['host'] ?? '', '/');
|
||||
$this->username = $config['username'] ?? null;
|
||||
$this->apiToken = $config['api_token'] ?? null;
|
||||
$this->timeout = $config['timeout'] ?? 30;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return !empty($this->host) && !empty($this->username) && !empty($this->apiToken);
|
||||
}
|
||||
|
||||
public function getJobInfo(string $jobName): ?array
|
||||
{
|
||||
return $this->request("/job/{$jobName}/api/json");
|
||||
}
|
||||
|
||||
public function getBuildInfo(string $jobName, int $buildNumber): ?array
|
||||
{
|
||||
return $this->request("/job/{$jobName}/{$buildNumber}/api/json");
|
||||
}
|
||||
|
||||
public function getLastBuild(string $jobName): ?array
|
||||
{
|
||||
return $this->request("/job/{$jobName}/lastBuild/api/json");
|
||||
}
|
||||
|
||||
public function getBuilds(string $jobName, int $limit = 10): array
|
||||
{
|
||||
$jobInfo = $this->getJobInfo($jobName);
|
||||
if (!$jobInfo || empty($jobInfo['builds'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builds = array_slice($jobInfo['builds'], 0, $limit);
|
||||
$result = [];
|
||||
|
||||
foreach ($builds as $build) {
|
||||
$buildInfo = $this->getBuildInfo($jobName, $build['number']);
|
||||
if ($buildInfo) {
|
||||
$result[] = $buildInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function request(string $path): ?array
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
Log::warning('Jenkins client is not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $this->host . $path;
|
||||
|
||||
try {
|
||||
$response = Http::timeout($this->timeout)
|
||||
->withBasicAuth($this->username, $this->apiToken)
|
||||
->get($url);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('Jenkins API request failed', [
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Jenkins API request error', [
|
||||
'url' => $url,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Services\GitMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GitMonitorCacheCommand extends Command
|
||||
{
|
||||
@@ -16,11 +17,11 @@ class GitMonitorCacheCommand extends Command
|
||||
$cache = $monitor->refreshReleaseCache(true);
|
||||
|
||||
if (empty($cache)) {
|
||||
$this->warn('未获取到任何 release 版本信息,请检查配置。');
|
||||
Log::channel('git-monitor')->warning('未获取到任何 release 版本信息,请检查配置。');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
Log::channel('git-monitor')->info(sprintf(
|
||||
'已缓存 %d 个仓库的 release 分支信息。',
|
||||
count($cache['repositories'] ?? [])
|
||||
));
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
||||
|
||||
use App\Services\GitMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GitMonitorCheckCommand extends Command
|
||||
{
|
||||
@@ -24,11 +25,11 @@ class GitMonitorCheckCommand extends Command
|
||||
|
||||
foreach ($results as $repo => $result) {
|
||||
if (isset($result['error'])) {
|
||||
$this->error(sprintf('[%s] %s', $repo, $result['error']));
|
||||
Log::channel('git-monitor')->error(sprintf('[%s] %s', $repo, $result['error']));
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
Log::channel('git-monitor')->info(sprintf(
|
||||
'[%s] 分支 %s 已对齐 %s,扫描 %d 个提交。',
|
||||
$repo,
|
||||
$result['branch'],
|
||||
@@ -37,9 +38,9 @@ class GitMonitorCheckCommand extends Command
|
||||
));
|
||||
|
||||
if (!empty($result['issues']['develop_merges'])) {
|
||||
$this->warn(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges'])));
|
||||
Log::channel('git-monitor')->warning(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges'])));
|
||||
foreach ($result['issues']['develop_merges'] as $commit) {
|
||||
$this->warn(sprintf(
|
||||
Log::channel('git-monitor')->warning(sprintf(
|
||||
' • %s %s (%s)',
|
||||
substr($commit['hash'], 0, 8),
|
||||
$commit['subject'],
|
||||
@@ -49,9 +50,9 @@ class GitMonitorCheckCommand extends Command
|
||||
}
|
||||
|
||||
if (!empty($result['issues']['missing_functions'])) {
|
||||
$this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions'])));
|
||||
Log::channel('git-monitor')->warning(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions'])));
|
||||
foreach ($result['issues']['missing_functions'] as $issue) {
|
||||
$this->warn(sprintf(
|
||||
Log::channel('git-monitor')->warning(sprintf(
|
||||
' • %s %s (%s)',
|
||||
substr($issue['commit']['hash'], 0, 8),
|
||||
$issue['commit']['subject'],
|
||||
@@ -59,7 +60,7 @@ class GitMonitorCheckCommand extends Command
|
||||
));
|
||||
foreach ($issue['details'] as $detail) {
|
||||
$functions = implode(', ', array_slice($detail['functions'], 0, 5));
|
||||
$this->warn(sprintf(' %s => %s', $detail['file'], $functions));
|
||||
Log::channel('git-monitor')->warning(sprintf(' %s => %s', $detail['file'], $functions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Console/Commands/JenkinsMonitorCommand.php
Normal file
42
app/Console/Commands/JenkinsMonitorCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\JenkinsMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JenkinsMonitorCommand extends Command
|
||||
{
|
||||
protected $signature = 'jenkins:monitor';
|
||||
|
||||
protected $description = '轮询 Jenkins 检查新构建并发送钉钉通知';
|
||||
|
||||
public function handle(JenkinsMonitorService $service): void
|
||||
{
|
||||
Log::channel('jenkins-monitor')->info('开始检查 Jenkins 构建...');
|
||||
|
||||
$results = $service->checkAllProjects();
|
||||
|
||||
if (isset($results['skipped'])) {
|
||||
Log::channel('jenkins-monitor')->warning('跳过检查: ' . ($results['reason'] ?? 'unknown'));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($results as $slug => $result) {
|
||||
if (isset($result['skipped'])) {
|
||||
Log::channel('jenkins-monitor')->info(sprintf('[%s] 跳过: %s', $slug, $result['reason'] ?? 'unknown'));
|
||||
continue;
|
||||
}
|
||||
|
||||
$newBuilds = $result['new_builds'] ?? [];
|
||||
if (empty($newBuilds)) {
|
||||
Log::channel('jenkins-monitor')->info(sprintf('[%s] 无新构建', $slug));
|
||||
} else {
|
||||
Log::channel('jenkins-monitor')->info(sprintf('[%s] 发现 %d 个新构建: #%s', $slug, count($newBuilds), implode(', #', $newBuilds)));
|
||||
}
|
||||
}
|
||||
|
||||
Log::channel('jenkins-monitor')->info('检查完成');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Services\SlsService;
|
||||
use App\Services\AiService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LogAnalysisCommand extends Command
|
||||
{
|
||||
@@ -29,13 +30,13 @@ class LogAnalysisCommand extends Command
|
||||
): int {
|
||||
// 检查配置
|
||||
if (!$slsService->isConfigured()) {
|
||||
$this->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项');
|
||||
return Command::FAILURE;
|
||||
Log::channel('log-analysis')->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (!$aiService->isConfigured()) {
|
||||
$this->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项');
|
||||
return Command::FAILURE;
|
||||
Log::channel('log-analysis')->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 解析时间参数
|
||||
@@ -43,8 +44,8 @@ class LogAnalysisCommand extends Command
|
||||
$to = $this->parseTime($this->option('to') ?? 'now');
|
||||
|
||||
if ($from >= $to) {
|
||||
$this->error('开始时间必须早于结束时间');
|
||||
return Command::FAILURE;
|
||||
Log::channel('log-analysis')->error('开始时间必须早于结束时间');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// 解析分析模式
|
||||
@@ -55,11 +56,10 @@ class LogAnalysisCommand extends Command
|
||||
|
||||
$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();
|
||||
Log::channel('log-analysis')->info("开始分析日志...");
|
||||
Log::channel('log-analysis')->info(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}");
|
||||
Log::channel('log-analysis')->info(" 查询语句: " . ($query ?: '*'));
|
||||
Log::channel('log-analysis')->info(" 分析模式: {$mode->label()}");
|
||||
|
||||
try {
|
||||
$result = $analysisService->analyze(
|
||||
@@ -74,27 +74,27 @@ class LogAnalysisCommand extends Command
|
||||
if ($outputPath = $this->option('output')) {
|
||||
$json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($outputPath, $json);
|
||||
$this->info("报告已保存到: {$outputPath}");
|
||||
Log::channel('log-analysis')->info("报告已保存到: {$outputPath}");
|
||||
}
|
||||
|
||||
// 推送到钉钉
|
||||
if ($this->option('push')) {
|
||||
$this->line("正在推送到钉钉...");
|
||||
Log::channel('log-analysis')->info("正在推送到钉钉...");
|
||||
$pushed = $analysisService->pushToNotification($result);
|
||||
if ($pushed) {
|
||||
$this->info("已推送到钉钉");
|
||||
Log::channel('log-analysis')->info("已推送到钉钉");
|
||||
} else {
|
||||
$this->warn("钉钉推送失败");
|
||||
Log::channel('log-analysis')->warning("钉钉推送失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 显示摘要
|
||||
$this->displaySummary($result);
|
||||
|
||||
return Command::SUCCESS;
|
||||
return self::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("分析失败: {$e->getMessage()}");
|
||||
return Command::FAILURE;
|
||||
Log::channel('log-analysis')->error("分析失败: {$e->getMessage()}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,57 +132,44 @@ class LogAnalysisCommand extends Command
|
||||
*/
|
||||
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();
|
||||
Log::channel('log-analysis')->info('=== 分析摘要 ===');
|
||||
Log::channel('log-analysis')->info("总日志数: {$result['metadata']['total_logs']}");
|
||||
Log::channel('log-analysis')->info("分析应用数: {$result['metadata']['apps_analyzed']}");
|
||||
Log::channel('log-analysis')->info("执行时间: {$result['metadata']['execution_time_ms']}ms");
|
||||
|
||||
if (empty($result['results'])) {
|
||||
$this->warn('未找到匹配的日志');
|
||||
Log::channel('log-analysis')->warning('未找到匹配的日志');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($result['results'] as $appName => $appResult) {
|
||||
$this->line("【{$appName}】");
|
||||
Log::channel('log-analysis')->info("【{$appName}】");
|
||||
|
||||
if (isset($appResult['error'])) {
|
||||
$this->error(" 分析失败: {$appResult['error']}");
|
||||
Log::channel('log-analysis')->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'));
|
||||
Log::channel('log-analysis')->info(" 日志数: {$appResult['log_count']}");
|
||||
Log::channel('log-analysis')->info(" 影响级别: {$impact}");
|
||||
Log::channel('log-analysis')->info(" 摘要: " . ($appResult['summary'] ?? 'N/A'));
|
||||
|
||||
$anomalies = $appResult['core_anomalies'] ?? [];
|
||||
if (!empty($anomalies)) {
|
||||
$this->line(" 异常数: " . count($anomalies));
|
||||
Log::channel('log-analysis')->info(" 异常数: " . count($anomalies));
|
||||
|
||||
$table = [];
|
||||
foreach (array_slice($anomalies, 0, 5) as $anomaly) {
|
||||
$table[] = [
|
||||
Log::channel('log-analysis')->info(sprintf(
|
||||
" - [%s] %s (数量: %d) - %s",
|
||||
$anomaly['type'] ?? 'N/A',
|
||||
$anomaly['classification'] ?? 'N/A',
|
||||
$anomaly['count'] ?? 1,
|
||||
mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40),
|
||||
];
|
||||
mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40)
|
||||
));
|
||||
}
|
||||
|
||||
$this->table(['类型', '分类', '数量', '可能原因'], $table);
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Console/Commands/ScheduledTaskRefreshCommand.php
Normal file
41
app/Console/Commands/ScheduledTaskRefreshCommand.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ScheduledTaskService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScheduledTaskRefreshCommand extends Command
|
||||
{
|
||||
protected $signature = 'scheduled-task:refresh';
|
||||
|
||||
protected $description = '刷新定时任务列表,同步 console.php 中的任务配置到数据库';
|
||||
|
||||
public function handle(ScheduledTaskService $taskService): int
|
||||
{
|
||||
try {
|
||||
Log::channel('scheduled-tasks')->info('开始刷新定时任务列表...');
|
||||
|
||||
$tasks = $taskService->getAllTasks();
|
||||
|
||||
Log::channel('scheduled-tasks')->info(sprintf('成功刷新 %d 个定时任务', count($tasks)));
|
||||
|
||||
// 显示任务列表
|
||||
foreach ($tasks as $task) {
|
||||
Log::channel('scheduled-tasks')->info(sprintf(
|
||||
' - %s: %s (%s) [%s]',
|
||||
$task['name'],
|
||||
$task['description'],
|
||||
$task['frequency'],
|
||||
$task['enabled'] ? '已启用' : '已禁用'
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-tasks')->error("刷新失败: {$e->getMessage()}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Admin/JenkinsDeploymentController.php
Normal file
41
app/Http/Controllers/Admin/JenkinsDeploymentController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\JenkinsDeployment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class JenkinsDeploymentController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = JenkinsDeployment::with('project:id,slug,name')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($request->filled('project_id')) {
|
||||
$query->where('project_id', $request->input('project_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('job_name')) {
|
||||
$query->where('job_name', $request->input('job_name'));
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->input('per_page', 20), 100);
|
||||
$deployments = $query->paginate($perPage);
|
||||
|
||||
return response()->json($deployments);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$deployment = JenkinsDeployment::with('project:id,slug,name')->findOrFail($id);
|
||||
|
||||
return response()->json($deployment);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ class ProjectController extends Controller
|
||||
'log_app_names' => ['nullable', 'array'],
|
||||
'log_app_names.*' => ['string', 'max:100'],
|
||||
'log_env' => ['nullable', 'string', 'max:50'],
|
||||
'jenkins_job_name' => ['nullable', 'string', 'max:255'],
|
||||
'jenkins_notify_enabled' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$project = $this->projectService->create($data);
|
||||
@@ -126,6 +128,8 @@ class ProjectController extends Controller
|
||||
'log_app_names' => ['nullable', 'array'],
|
||||
'log_app_names.*' => ['string', 'max:100'],
|
||||
'log_env' => ['nullable', 'string', 'max:50'],
|
||||
'jenkins_job_name' => ['nullable', 'string', 'max:255'],
|
||||
'jenkins_notify_enabled' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$project = $this->projectService->update($project, $data);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
69
app/Models/JenkinsDeployment.php
Normal file
69
app/Models/JenkinsDeployment.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class JenkinsDeployment extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'project_id',
|
||||
'build_number',
|
||||
'job_name',
|
||||
'status',
|
||||
'branch',
|
||||
'commit_sha',
|
||||
'triggered_by',
|
||||
'duration',
|
||||
'build_url',
|
||||
'raw_data',
|
||||
'build_params',
|
||||
'notified',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'raw_data' => 'array',
|
||||
'build_params' => 'array',
|
||||
'notified' => 'boolean',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function getFormattedDuration(): string
|
||||
{
|
||||
if (!$this->duration) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$seconds = (int) ($this->duration / 1000);
|
||||
$minutes = (int) ($seconds / 60);
|
||||
$seconds = $seconds % 60;
|
||||
|
||||
return sprintf('%02d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
public function getStatusEmoji(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'SUCCESS' => '✅',
|
||||
'FAILURE' => '❌',
|
||||
'ABORTED' => '⏹️',
|
||||
'UNSTABLE' => '⚠️',
|
||||
default => '❓',
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'SUCCESS' => '成功',
|
||||
'FAILURE' => '失败',
|
||||
'ABORTED' => '已中止',
|
||||
'UNSTABLE' => '不稳定',
|
||||
default => '未知',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,16 @@ class Project extends BaseModel
|
||||
'git_version_cached_at',
|
||||
'log_app_names',
|
||||
'log_env',
|
||||
'jenkins_job_name',
|
||||
'jenkins_notify_enabled',
|
||||
'jenkins_last_notified_build',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'git_monitor_enabled' => 'boolean',
|
||||
'auto_create_release_branch' => 'boolean',
|
||||
'is_important' => 'boolean',
|
||||
'jenkins_notify_enabled' => 'boolean',
|
||||
'log_app_names' => 'array',
|
||||
'git_version_cached_at' => 'datetime',
|
||||
];
|
||||
@@ -86,4 +90,15 @@ class Project extends BaseModel
|
||||
->whereJsonContains('log_app_names', $appName)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用 Jenkins 通知的项目
|
||||
*/
|
||||
public static function getJenkinsNotifyEnabled(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return static::query()
|
||||
->where('jenkins_notify_enabled', true)
|
||||
->whereNotNull('jenkins_job_name')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
21
app/Models/ScheduledTask.php
Normal file
21
app/Models/ScheduledTask.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ScheduledTask extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'command',
|
||||
'description',
|
||||
'frequency',
|
||||
'cron',
|
||||
'enabled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置超时时间
|
||||
*/
|
||||
|
||||
@@ -11,8 +11,7 @@ class CodeContextService
|
||||
private int $contextLines = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConfigService $configService,
|
||||
private readonly EnvService $envService
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -23,49 +22,16 @@ class CodeContextService
|
||||
*/
|
||||
public function getRepoPath(string $appName): ?string
|
||||
{
|
||||
// 优先从 Project 模型查找
|
||||
// 从 Project 模型查找,直接使用项目路径
|
||||
$project = Project::findByAppName($appName);
|
||||
|
||||
if ($project) {
|
||||
$env = $project->log_env ?? 'production';
|
||||
try {
|
||||
$envContent = $this->envService->getEnvContent($project->slug, $env);
|
||||
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
|
||||
|
||||
if ($repoPath && is_dir($repoPath)) {
|
||||
return $repoPath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略错误,继续尝试旧配置
|
||||
$projectsPath = $this->configService->get('workspace.projects_path', '');
|
||||
if ($projectsPath && $project->isPathValid($projectsPath)) {
|
||||
return $project->getFullPath($projectsPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到旧的配置方式(兼容迁移前的情况)
|
||||
$appEnvMap = $this->configService->get('log_analysis.app_env_map', []);
|
||||
|
||||
if (!isset($appEnvMap[$appName])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mapping = $appEnvMap[$appName];
|
||||
$projectSlug = $mapping['project'] ?? null;
|
||||
$env = $mapping['env'] ?? null;
|
||||
|
||||
if (!$projectSlug || !$env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$envContent = $this->envService->getEnvContent($projectSlug, $env);
|
||||
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
|
||||
|
||||
if ($repoPath && is_dir($repoPath)) {
|
||||
return $repoPath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略错误,返回 null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -246,36 +212,6 @@ class CodeContextService
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置上下文行数
|
||||
*/
|
||||
|
||||
294
app/Services/JenkinsMonitorService.php
Normal file
294
app/Services/JenkinsMonitorService.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\JenkinsClient;
|
||||
use App\Models\JenkinsDeployment;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JenkinsMonitorService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly JenkinsClient $jenkinsClient,
|
||||
private readonly DingTalkService $dingTalkService,
|
||||
private readonly ConfigService $configService
|
||||
) {}
|
||||
|
||||
public function checkAllProjects(): array
|
||||
{
|
||||
if (!$this->jenkinsClient->isConfigured()) {
|
||||
Log::warning('Jenkins client is not configured, skipping monitor');
|
||||
return ['skipped' => true, 'reason' => 'Jenkins not configured'];
|
||||
}
|
||||
|
||||
$projects = Project::getJenkinsNotifyEnabled();
|
||||
$results = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$results[$project->slug] = $this->checkProject($project);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function checkProject(Project $project): array
|
||||
{
|
||||
if (empty($project->jenkins_job_name)) {
|
||||
return ['skipped' => true, 'reason' => 'No Jenkins job configured'];
|
||||
}
|
||||
|
||||
$jobName = $project->jenkins_job_name;
|
||||
$lastNotifiedBuild = $project->jenkins_last_notified_build ?? 0;
|
||||
$allowedTriggers = $this->getAllowedTriggers();
|
||||
|
||||
$builds = $this->jenkinsClient->getBuilds($jobName, 5);
|
||||
$newBuilds = [];
|
||||
|
||||
foreach ($builds as $build) {
|
||||
// 只处理已完成的构建
|
||||
if ($build['building'] ?? true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$buildNumber = $build['number'];
|
||||
|
||||
// 跳过已通知的构建
|
||||
if ($buildNumber <= $lastNotifiedBuild) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否已存在记录
|
||||
$exists = JenkinsDeployment::where('job_name', $jobName)
|
||||
->where('build_number', $buildNumber)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析构建信息
|
||||
$triggeredBy = $this->extractTriggeredBy($build);
|
||||
$branch = $this->extractBranch($build);
|
||||
$commitSha = $this->extractCommitSha($build);
|
||||
$buildParams = $this->extractBuildParams($build);
|
||||
|
||||
// 过滤触发者(只通知指定用户触发的构建)
|
||||
// 如果没有配置允许的触发者列表,则跳过所有通知
|
||||
if (empty($allowedTriggers)) {
|
||||
Log::info('Skipping build - no allowed triggers configured', [
|
||||
'job' => $jobName,
|
||||
'build' => $buildNumber,
|
||||
'triggered_by' => $triggeredBy,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查触发者是否在允许列表中
|
||||
if (!$this->isAllowedTrigger($triggeredBy, $allowedTriggers)) {
|
||||
Log::info('Skipping build due to trigger filter', [
|
||||
'job' => $jobName,
|
||||
'build' => $buildNumber,
|
||||
'triggered_by' => $triggeredBy,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保存发布记录
|
||||
$deployment = JenkinsDeployment::create([
|
||||
'project_id' => $project->id,
|
||||
'build_number' => $buildNumber,
|
||||
'job_name' => $jobName,
|
||||
'status' => $build['result'] ?? 'UNKNOWN',
|
||||
'branch' => $branch,
|
||||
'commit_sha' => $commitSha,
|
||||
'triggered_by' => $triggeredBy,
|
||||
'duration' => $build['duration'] ?? null,
|
||||
'build_url' => $build['url'] ?? null,
|
||||
'raw_data' => $build,
|
||||
'build_params' => $buildParams,
|
||||
'notified' => false,
|
||||
]);
|
||||
|
||||
// 发送通知
|
||||
$this->sendNotification($project, $deployment);
|
||||
|
||||
$deployment->update(['notified' => true]);
|
||||
$newBuilds[] = $buildNumber;
|
||||
}
|
||||
|
||||
// 更新最后通知的构建号
|
||||
if (!empty($newBuilds)) {
|
||||
$project->update([
|
||||
'jenkins_last_notified_build' => max($newBuilds),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'job' => $jobName,
|
||||
'new_builds' => $newBuilds,
|
||||
];
|
||||
}
|
||||
|
||||
private function sendNotification(Project $project, JenkinsDeployment $deployment): void
|
||||
{
|
||||
$lines = [];
|
||||
$lines[] = sprintf(
|
||||
"%s 【Jenkins 发布通知】",
|
||||
$deployment->getStatusEmoji()
|
||||
);
|
||||
$lines[] = sprintf("项目: %s", $project->name);
|
||||
$lines[] = sprintf("状态: %s", $deployment->getStatusLabel());
|
||||
$lines[] = sprintf("构建号: #%d", $deployment->build_number);
|
||||
$lines[] = sprintf("触发者: %s", $deployment->triggered_by ?? '-');
|
||||
$lines[] = sprintf("耗时: %s", $deployment->getFormattedDuration());
|
||||
|
||||
// 添加构建参数
|
||||
if (!empty($deployment->build_params)) {
|
||||
$lines[] = "\n构建参数:";
|
||||
foreach ($deployment->build_params as $key => $value) {
|
||||
// 格式化参数值
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 'true' : 'false';
|
||||
} elseif (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
} elseif ($value === null || $value === '') {
|
||||
continue; // 跳过空值
|
||||
}
|
||||
$lines[] = sprintf(" %s: %s", $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = sprintf("\n详情: %s", $deployment->build_url ?? '-');
|
||||
|
||||
$message = implode("\n", $lines);
|
||||
$this->dingTalkService->sendText($message);
|
||||
}
|
||||
|
||||
private function extractTriggeredBy(array $build): ?string
|
||||
{
|
||||
$actions = $build['actions'] ?? [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
// UserIdCause - 用户手动触发
|
||||
if (isset($action['causes'])) {
|
||||
foreach ($action['causes'] as $cause) {
|
||||
if (isset($cause['userId'])) {
|
||||
return $cause['userId'];
|
||||
}
|
||||
if (isset($cause['userName'])) {
|
||||
return $cause['userName'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractBranch(array $build): ?string
|
||||
{
|
||||
$actions = $build['actions'] ?? [];
|
||||
|
||||
// 优先从参数中获取 branchName
|
||||
foreach ($actions as $action) {
|
||||
if (isset($action['parameters'])) {
|
||||
foreach ($action['parameters'] as $param) {
|
||||
if (in_array($param['name'] ?? '', ['branchName', 'BRANCH', 'branch', 'GIT_BRANCH', 'BRANCH_NAME'])) {
|
||||
$value = $param['value'] ?? null;
|
||||
if (!empty($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果参数中没有,再从 Git 分支信息中获取
|
||||
foreach ($actions as $action) {
|
||||
if (isset($action['lastBuiltRevision']['branch'])) {
|
||||
foreach ($action['lastBuiltRevision']['branch'] as $branch) {
|
||||
$name = $branch['name'] ?? '';
|
||||
// 移除 origin/ 和 refs/remotes/origin/ 前缀
|
||||
return preg_replace('/^(refs\/remotes\/origin\/|origin\/)/', '', $name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractCommitSha(array $build): ?string
|
||||
{
|
||||
$actions = $build['actions'] ?? [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
if (isset($action['lastBuiltRevision']['SHA1'])) {
|
||||
return substr($action['lastBuiltRevision']['SHA1'], 0, 8);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getAllowedTriggers(): array
|
||||
{
|
||||
$config = $this->configService->get('jenkins_allowed_triggers', []);
|
||||
|
||||
// 如果配置为空,返回空数组
|
||||
if (empty($config)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 如果配置是数组,直接返回(过滤空值)
|
||||
if (is_array($config)) {
|
||||
return array_filter(array_map('trim', $config));
|
||||
}
|
||||
|
||||
// 如果配置是字符串(兼容旧格式),按逗号分隔
|
||||
if (is_string($config)) {
|
||||
return array_filter(array_map('trim', explode(',', $config)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function isAllowedTrigger(?string $triggeredBy, array $allowedTriggers): bool
|
||||
{
|
||||
if (empty($triggeredBy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($allowedTriggers as $allowed) {
|
||||
if (strcasecmp($triggeredBy, $allowed) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function extractBuildParams(array $build): array
|
||||
{
|
||||
$actions = $build['actions'] ?? [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
// 查找 ParametersAction
|
||||
if (isset($action['_class']) && $action['_class'] === 'hudson.model.ParametersAction') {
|
||||
if (isset($action['parameters']) && is_array($action['parameters'])) {
|
||||
$params = [];
|
||||
foreach ($action['parameters'] as $param) {
|
||||
$name = $param['name'] ?? null;
|
||||
$value = $param['value'] ?? null;
|
||||
if ($name !== null) {
|
||||
$params[$name] = $value;
|
||||
}
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -170,7 +179,7 @@ class JiraService
|
||||
$endOfWeek = $now->copy()->subWeek()->endOfWeek();
|
||||
|
||||
$workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek);
|
||||
$organizedTasks = $this->organizeTasksForReport($workLogs);
|
||||
$organizedTasks = $this->organizeTasksForReport($workLogs, $username);
|
||||
|
||||
$nextWeekTasks = $this->getNextWeekTasks($username);
|
||||
|
||||
@@ -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";
|
||||
@@ -307,11 +335,14 @@ class JiraService
|
||||
'created',
|
||||
'fixVersions',
|
||||
'labels',
|
||||
'customfield_10004', // Sprint字段
|
||||
'customfield_10900', // Bug发现阶段
|
||||
'customfield_12700', // Bug错误类型
|
||||
'customfield_10115', // Bug修复描述
|
||||
'customfield_14305', // 需求类型
|
||||
'assignee', // 经办人
|
||||
'customfield_10004', // Sprint字段
|
||||
'customfield_10900', // Bug发现阶段
|
||||
'customfield_11000', // 开发人
|
||||
'customfield_11301', // 实际修复人
|
||||
'customfield_12700', // Bug错误类型
|
||||
'customfield_10115', // Bug修复描述
|
||||
'customfield_14305', // 需求类型
|
||||
]);
|
||||
|
||||
if (!empty($issues->issues)) {
|
||||
@@ -369,6 +400,11 @@ class JiraService
|
||||
// 提取需求类型
|
||||
$requirementType = $this->extractRequirementType($issue);
|
||||
|
||||
// 提取经办人、开发人、实际修复人
|
||||
$assignee = $this->extractAssignee($issue);
|
||||
$developer = $this->extractDeveloper($issue);
|
||||
$actualFixer = $this->extractActualFixer($issue);
|
||||
|
||||
$workLogs->push([
|
||||
'id' => $worklog->id ?? '',
|
||||
'project' => $issue->fields->project->name ?? '',
|
||||
@@ -385,6 +421,9 @@ class JiraService
|
||||
'bug_type' => $bugType,
|
||||
'bug_description' => $bugDescription,
|
||||
'requirement_type' => $requirementType,
|
||||
'assignee' => $assignee,
|
||||
'developer' => $developer,
|
||||
'actual_fixer' => $actualFixer,
|
||||
'date' => $worklogDate->format('Y-m-d'),
|
||||
'time' => $worklogDate->format('H:i'),
|
||||
'hours' => round(($worklog->timeSpentSeconds ?? 0) / 3600, 2),
|
||||
@@ -593,6 +632,60 @@ class JiraService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取经办人
|
||||
*/
|
||||
private function extractAssignee($issue): ?string
|
||||
{
|
||||
// 从assignee字段获取经办人
|
||||
if (isset($issue->fields->assignee)) {
|
||||
$assignee = $issue->fields->assignee;
|
||||
|
||||
// 处理对象类型
|
||||
if (is_object($assignee)) {
|
||||
return $assignee->name ?? $assignee->key ?? null;
|
||||
} elseif (is_string($assignee)) {
|
||||
return $assignee;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取开发人
|
||||
*/
|
||||
private function extractDeveloper($issue): ?string
|
||||
{
|
||||
// 从customfield_11000获取开发人
|
||||
if (isset($issue->fields->customFields['customfield_11000'])) {
|
||||
$developer = $issue->fields->customFields['customfield_11000'];
|
||||
|
||||
if (is_string($developer) && !empty($developer)) {
|
||||
return $developer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取实际修复人
|
||||
*/
|
||||
private function extractActualFixer($issue): ?string
|
||||
{
|
||||
// 从customfield_11301获取实际修复人
|
||||
if (isset($issue->fields->customFields['customfield_11301'])) {
|
||||
$fixer = $issue->fields->customFields['customfield_11301'];
|
||||
|
||||
if (is_string($fixer) && !empty($fixer)) {
|
||||
return $fixer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理摘要中的图片链接
|
||||
*/
|
||||
@@ -610,32 +703,32 @@ class JiraService
|
||||
*/
|
||||
private function isTaskCompleted(string $status): bool
|
||||
{
|
||||
// 只有已完成或已取消才打勾
|
||||
$completedStatuses = [
|
||||
'已完成',
|
||||
'完成',
|
||||
'Done',
|
||||
'Closed',
|
||||
'Resolved',
|
||||
];
|
||||
$cancelledStatuses = [
|
||||
'已取消',
|
||||
'取消',
|
||||
'Cancelled',
|
||||
'Canceled',
|
||||
// 定义"未完成"的状态列表(包括未开始和进行中)
|
||||
// 如果状态不在这个列表中,则认为任务已完成
|
||||
$incompleteStatuses = [
|
||||
'未开始',
|
||||
'需求已确认',
|
||||
'开发中',
|
||||
'需求调研中',
|
||||
'需求已调研',
|
||||
'需求已评审',
|
||||
'需求已排期',
|
||||
'待提测',
|
||||
'需求设计中',
|
||||
];
|
||||
|
||||
return in_array($status, $completedStatuses, true)
|
||||
|| in_array($status, $cancelledStatuses, true);
|
||||
// 如果状态不在"未完成"列表中,则标记为已完成
|
||||
return !in_array($status, $incompleteStatuses, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织任务数据用于周报生成
|
||||
*/
|
||||
private function organizeTasksForReport(Collection $workLogs): Collection
|
||||
private function organizeTasksForReport(Collection $workLogs, string $username): Collection
|
||||
{
|
||||
$organized = collect([
|
||||
'sprints' => collect(),
|
||||
'stories' => collect(), // 没有Sprint的需求
|
||||
'tasks' => collect(),
|
||||
'bugs' => collect(),
|
||||
]);
|
||||
@@ -671,6 +764,21 @@ class JiraService
|
||||
$isSubtask = in_array($issueType, ['Sub-task', 'sub-task', '子任务']);
|
||||
|
||||
if ($isBug && $workLog['bug_stage']) {
|
||||
// Bug过滤逻辑:必须经办人、实际修复人或开发人是当前用户
|
||||
$assignee = $workLog['assignee'] ?? null;
|
||||
$developer = $workLog['developer'] ?? null;
|
||||
$actualFixer = $workLog['actual_fixer'] ?? null;
|
||||
|
||||
// 检查是否有任一字段匹配当前用户
|
||||
$isUserRelated = ($assignee === $username)
|
||||
|| ($developer === $username)
|
||||
|| ($actualFixer === $username);
|
||||
|
||||
// 如果不是当前用户相关的Bug,跳过
|
||||
if (!$isUserRelated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bug按发现阶段分类
|
||||
$stage = $workLog['bug_stage'];
|
||||
if (!$organized['bugs']->has($stage)) {
|
||||
@@ -701,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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字段
|
||||
*/
|
||||
|
||||
@@ -44,7 +44,7 @@ class ScheduledTaskService
|
||||
$tasks[] = [
|
||||
'name' => $name,
|
||||
'command' => $this->getEventCommand($event),
|
||||
'description' => $event->description ?: $name,
|
||||
'description' => $this->getTaskDescription($name),
|
||||
'frequency' => $this->getFrequencyLabel($event->expression),
|
||||
'cron' => $event->expression,
|
||||
'enabled' => $enabledTasks[$name] ?? false,
|
||||
@@ -92,9 +92,15 @@ class ScheduledTaskService
|
||||
|
||||
private function getEventName($event): string
|
||||
{
|
||||
if (property_exists($event, 'mutexName') && $event->mutexName) {
|
||||
return $event->mutexName;
|
||||
// Laravel Schedule 事件的 description 属性存储任务名称
|
||||
// 我们在 routes/console.php 中通过 ->description() 设置
|
||||
|
||||
// 1. 优先使用 description (我们设置的任务标识符)
|
||||
if (property_exists($event, 'description') && $event->description) {
|
||||
return $event->description;
|
||||
}
|
||||
|
||||
// 2. 最后使用命令作为名称
|
||||
return $this->getEventCommand($event);
|
||||
}
|
||||
|
||||
@@ -125,9 +131,27 @@ class ScheduledTaskService
|
||||
'0 */12 * * *' => '每 12 小时',
|
||||
'0 0 * * *' => '每天凌晨 0:00',
|
||||
'0 2 * * *' => '每天凌晨 2:00',
|
||||
'0 3 * * *' => '每天凌晨 3:00',
|
||||
'0 0 * * 0' => '每周日凌晨',
|
||||
'0 0 1 * *' => '每月 1 日凌晨',
|
||||
];
|
||||
return $map[$expression] ?? $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务的友好描述文本
|
||||
*/
|
||||
private function getTaskDescription(string $name): string
|
||||
{
|
||||
$descriptions = [
|
||||
'git-monitor-check' => 'Git 监控 - 检查 release 分支变化',
|
||||
'git-monitor-cache' => 'Git 监控 - 刷新 release 缓存',
|
||||
'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志',
|
||||
'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志',
|
||||
'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知',
|
||||
'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表',
|
||||
'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志',
|
||||
];
|
||||
return $descriptions[$name] ?? $name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
125
composer.lock
generated
@@ -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",
|
||||
|
||||
8
config/jenkins.php
Normal file
8
config/jenkins.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => env('JENKINS_HOST'),
|
||||
'username' => env('JENKINS_USERNAME'),
|
||||
'api_token' => env('JENKINS_API_TOKEN'),
|
||||
'timeout' => (int) env('JENKINS_TIMEOUT', 30),
|
||||
];
|
||||
@@ -127,6 +127,38 @@ return [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
'jenkins-monitor' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/scheduled-tasks/jenkins-monitor.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => 7,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'git-monitor' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/scheduled-tasks/git-monitor.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => 7,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'log-analysis' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/scheduled-tasks/log-analysis.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => 7,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'scheduled-tasks' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/scheduled-tasks/scheduled-tasks.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => 7,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->string('jenkins_job_name', 255)->nullable()->comment('Jenkins Job 名称');
|
||||
$table->boolean('jenkins_notify_enabled')->default(false)->comment('是否启用 Jenkins 通知');
|
||||
$table->integer('jenkins_last_notified_build')->nullable()->comment('最后通知的构建号');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn(['jenkins_job_name', 'jenkins_notify_enabled', 'jenkins_last_notified_build']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jenkins_deployments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->nullable()->constrained()->onDelete('cascade');
|
||||
$table->integer('build_number');
|
||||
$table->string('job_name', 255);
|
||||
$table->string('status', 20)->comment('SUCCESS, FAILURE, ABORTED, UNSTABLE');
|
||||
$table->string('branch', 255)->nullable();
|
||||
$table->string('commit_sha', 64)->nullable();
|
||||
$table->string('triggered_by', 100)->nullable();
|
||||
$table->integer('duration')->nullable()->comment('构建耗时(毫秒)');
|
||||
$table->string('build_url', 500)->nullable();
|
||||
$table->json('raw_data')->nullable();
|
||||
$table->boolean('notified')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['job_name', 'build_number']);
|
||||
$table->index(['project_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jenkins_deployments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('scheduled_tasks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique()->comment('任务唯一标识符');
|
||||
$table->string('command')->comment('任务命令');
|
||||
$table->string('description')->nullable()->comment('任务描述');
|
||||
$table->string('frequency')->comment('执行频率描述');
|
||||
$table->string('cron')->comment('Cron 表达式');
|
||||
$table->boolean('enabled')->default(false)->comment('是否启用');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('scheduled_tasks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('jenkins_deployments', function (Blueprint $table) {
|
||||
$table->json('build_params')->nullable()->after('raw_data')->comment('构建参数');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('jenkins_deployments', function (Blueprint $table) {
|
||||
$table->dropColumn('build_params');
|
||||
});
|
||||
}
|
||||
};
|
||||
17
patches.lock.json
Normal file
17
patches.lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
20
patches/aliyun-log-php-sdk-php8-fix.patch
Normal file
20
patches/aliyun-log-php-sdk-php8-fix.patch
Normal 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
|
||||
@@ -181,6 +181,22 @@
|
||||
<input v-model="form.is_important" type="checkbox" id="is_important" class="rounded border-gray-300 text-yellow-500 focus:ring-yellow-500" />
|
||||
<label for="is_important" class="text-sm text-gray-700">标记为重要项目</label>
|
||||
</div>
|
||||
|
||||
<!-- Jenkins 配置 -->
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 mb-3">Jenkins 发布通知</h5>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Jenkins Job 名称</label>
|
||||
<input v-model="form.jenkins_job_name" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-be-deploy" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="form.jenkins_notify_enabled" type="checkbox" id="jenkins_notify" class="rounded border-gray-300 text-orange-500 focus:ring-orange-500" />
|
||||
<label for="jenkins_notify" class="text-sm text-gray-700">启用 Jenkins 发布通知</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">日志 App 名称 (逗号分隔)</label>
|
||||
<input v-model="form.log_app_names_text" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-api, portal-worker" />
|
||||
@@ -309,6 +325,10 @@ const ProjectCard = {
|
||||
<span class="text-gray-500">版本:</span>
|
||||
<span class="font-mono text-gray-700">{{ project.git_current_version }}</span>
|
||||
</div>
|
||||
<div v-if="project.jenkins_job_name" class="flex items-center gap-2">
|
||||
<span class="text-gray-500">Jenkins:</span>
|
||||
<span class="font-mono bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded text-xs">{{ project.jenkins_job_name }}</span>
|
||||
</div>
|
||||
<div v-if="project.log_app_names?.length" class="flex items-center gap-2">
|
||||
<span class="text-gray-500">App:</span>
|
||||
<span class="text-gray-700">{{ project.log_app_names.join(', ') }}</span>
|
||||
@@ -333,6 +353,14 @@ const ProjectCard = {
|
||||
</span>
|
||||
<span class="text-xs" :class="project.auto_create_release_branch ? 'text-purple-600' : 'text-gray-500'">自动创建分支</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'jenkins_notify_enabled')">
|
||||
<span class="relative inline-block">
|
||||
<input type="checkbox" :checked="project.jenkins_notify_enabled" class="sr-only peer" />
|
||||
<span class="block w-8 h-4 bg-gray-200 rounded-full peer peer-checked:bg-orange-500 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></span>
|
||||
</span>
|
||||
<span class="text-xs" :class="project.jenkins_notify_enabled ? 'text-orange-600' : 'text-gray-500'">Jenkins通知</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'is_important')">
|
||||
<span class="relative inline-block">
|
||||
<input type="checkbox" :checked="project.is_important" class="sr-only peer" />
|
||||
@@ -418,6 +446,8 @@ export default {
|
||||
git_monitor_enabled: false,
|
||||
auto_create_release_branch: false,
|
||||
is_important: false,
|
||||
jenkins_job_name: '',
|
||||
jenkins_notify_enabled: false,
|
||||
log_app_names_text: '',
|
||||
log_env: 'production'
|
||||
};
|
||||
@@ -456,6 +486,8 @@ export default {
|
||||
git_monitor_enabled: project.git_monitor_enabled || false,
|
||||
auto_create_release_branch: project.auto_create_release_branch || false,
|
||||
is_important: project.is_important || false,
|
||||
jenkins_job_name: project.jenkins_job_name || '',
|
||||
jenkins_notify_enabled: project.jenkins_notify_enabled || false,
|
||||
log_app_names_text: (project.log_app_names || []).join(', '),
|
||||
log_env: project.log_env || 'production'
|
||||
};
|
||||
@@ -483,6 +515,8 @@ export default {
|
||||
git_monitor_enabled: this.form.git_monitor_enabled,
|
||||
auto_create_release_branch: this.form.auto_create_release_branch,
|
||||
is_important: this.form.is_important,
|
||||
jenkins_job_name: this.form.jenkins_job_name || null,
|
||||
jenkins_notify_enabled: this.form.jenkins_notify_enabled,
|
||||
log_app_names: this.form.log_app_names_text ? this.form.log_app_names_text.split(',').map(s => s.trim()).filter(Boolean) : null,
|
||||
log_env: this.form.log_env || null
|
||||
};
|
||||
|
||||
@@ -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,每行一个 例如: af7e5ca7-2779-0e9e-93d1-68c79ceffd9033 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 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>
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\SqlGeneratorController;
|
||||
use App\Http\Controllers\Admin\AdminMetaController;
|
||||
use App\Http\Controllers\Admin\ConfigController;
|
||||
use App\Http\Controllers\Admin\IpUserMappingController;
|
||||
use App\Http\Controllers\Admin\JenkinsDeploymentController;
|
||||
use App\Http\Controllers\Admin\OperationLogController;
|
||||
use App\Http\Controllers\Admin\ProjectController;
|
||||
use App\Http\Controllers\Admin\ScheduledTaskController;
|
||||
@@ -94,6 +95,10 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
|
||||
// 定时任务管理
|
||||
Route::get('/scheduled-tasks', [ScheduledTaskController::class, 'index']);
|
||||
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
|
||||
|
||||
// Jenkins 发布历史
|
||||
Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']);
|
||||
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
|
||||
});
|
||||
|
||||
// 日志分析 API 路由
|
||||
|
||||
@@ -23,36 +23,47 @@ Artisan::command('inspire', function () {
|
||||
// Git Monitor - 每 10 分钟检查 release 分支
|
||||
Schedule::command('git-monitor:check')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->withoutOverlapping(10)
|
||||
->runInBackground()
|
||||
->name('git-monitor-check')
|
||||
->description('Git 监控 - 检查 release 分支变化')
|
||||
->description('git-monitor-check')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check'));
|
||||
|
||||
// Git Monitor - 每天凌晨 2 点刷新 release 缓存
|
||||
Schedule::command('git-monitor:cache')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->name('git-monitor-cache')
|
||||
->description('Git 监控 - 刷新 release 缓存')
|
||||
->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()
|
||||
->name('daily-log-analysis')
|
||||
->description('SLS 日志分析 - 每日分析过去 24 小时日志')
|
||||
->description('daily-log-analysis')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('daily-log-analysis'))
|
||||
->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()
|
||||
->name('frequent-log-analysis')
|
||||
->description('SLS 日志分析 - 定期分析过去 6 小时日志')
|
||||
->description('frequent-log-analysis')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis'))
|
||||
->onFailure(fn() => Log::error('SLS 日志分析定时任务执行失败'));
|
||||
|
||||
// Jenkins Monitor - 每分钟检查新构建
|
||||
Schedule::command('jenkins:monitor')
|
||||
->everyMinute()
|
||||
->withoutOverlapping(10)
|
||||
->runInBackground()
|
||||
->description('jenkins-monitor')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('jenkins-monitor'));
|
||||
|
||||
// 定时任务刷新 - 每天凌晨 3 点刷新定时任务列表
|
||||
Schedule::command('scheduled-task:refresh')
|
||||
->dailyAt('03:00')
|
||||
->withoutOverlapping()
|
||||
->description('scheduled-task-refresh')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('scheduled-task-refresh'));
|
||||
|
||||
Reference in New Issue
Block a user