#add jira & message sync

This commit is contained in:
2025-12-02 10:16:32 +08:00
parent 5c4492d8f8
commit 2ec44b5665
49 changed files with 6633 additions and 1209 deletions

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Clients;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
class AgentClient
{
private string $baseUrl;
private PendingRequest $http;
public function __construct()
{
$this->baseUrl = config('services.agent.url');
$this->http = Http::timeout(config('services.agent.timeout', 30))
->withoutVerifying();
}
/**
* 分发消息到Agent
*/
public function dispatchMessage(array $data): Response
{
return $this->http->post($this->baseUrl . '/rpc/dispatchMessage', $data);
}
/**
* 测试连接
*/
public function testConnection(): Response
{
return $this->http->get($this->baseUrl . '/health');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Clients;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
class MonoClient
{
private string $baseUrl;
private PendingRequest $http;
public function __construct()
{
$this->baseUrl = config('services.mono.url');
$this->http = Http::timeout(config('services.mono.timeout', 30))
->withoutVerifying();
}
/**
* 测试连接
*/
public function testConnection(): Response
{
return $this->http->get($this->baseUrl . '/health');
}
/**
* 更新消息分发状态
*/
public function updateDispatch(array $data): Response
{
return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/update-dispatch', $data);
}
}

View File

@@ -8,9 +8,10 @@ use Illuminate\Console\Command;
class EnvCommand extends Command
{
protected $signature = 'env:manage
{action : 操作类型 (list|environments|apply|save|import|delete)}
{action : 操作类型 (list|environments|apply|save|import|delete|backups|restore|delete-backup)}
{--project= : 项目名称}
{--environment= : 环境名称}
{--backup= : 备份名称}
{--content= : 环境文件内容}';
protected $description = '环境文件管理工具';
@@ -41,6 +42,12 @@ class EnvCommand extends Command
return $this->importEnvironment();
case 'delete':
return $this->deleteEnvironment();
case 'backups':
return $this->listBackups();
case 'restore':
return $this->restoreBackup();
case 'delete-backup':
return $this->deleteBackup();
default:
$this->error("未知操作: {$action}");
$this->showUsage();
@@ -234,18 +241,113 @@ class EnvCommand extends Command
return 0;
}
/**
* 列出项目备份
*/
private function listBackups(): int
{
$project = $this->option('project');
if (!$project) {
$this->error('请指定项目名称: --project=项目名');
return 1;
}
$backups = $this->envManager->getProjectBackups($project);
if (empty($backups)) {
$this->info("项目 {$project} 没有备份文件");
return 0;
}
$this->info("项目 {$project} 的备份文件:");
$this->table(
['备份名称', '文件大小', '创建时间'],
array_map(function ($backup) {
return [
$backup['name'],
$this->formatBytes($backup['size']),
$backup['created_at']
];
}, $backups)
);
return 0;
}
/**
* 恢复备份
*/
private function restoreBackup(): int
{
$project = $this->option('project');
$backup = $this->option('backup');
if (!$project || !$backup) {
$this->error('请指定项目名称和备份名称: --project=项目名 --backup=备份名');
return 1;
}
if ($this->confirm("确定要将备份 {$backup} 恢复到项目 {$project} 吗?")) {
$success = $this->envManager->restoreBackup($project, $backup);
if ($success) {
$this->info("成功将备份 {$backup} 恢复到项目 {$project}");
return 0;
} else {
$this->error("恢复备份失败");
return 1;
}
}
$this->info('操作已取消');
return 0;
}
/**
* 删除备份
*/
private function deleteBackup(): int
{
$project = $this->option('project');
$backup = $this->option('backup');
if (!$project || !$backup) {
$this->error('请指定项目名称和备份名称: --project=项目名 --backup=备份名');
return 1;
}
if ($this->confirm("确定要删除备份 {$project}/{$backup} 吗?")) {
$success = $this->envManager->deleteBackup($project, $backup);
if ($success) {
$this->info("成功删除备份 {$project}/{$backup}");
return 0;
} else {
$this->error("删除备份失败");
return 1;
}
}
$this->info('操作已取消');
return 0;
}
/**
* 显示使用说明
*/
private function showUsage(): void
{
$this->info('使用说明:');
$this->line(' php artisan env:manage list # 列出所有项目');
$this->line(' php artisan env:manage environments --project=项目名 # 列出项目环境');
$this->line(' php artisan env:manage apply --project=项目名 --environment=环境名 # 应用环境');
$this->line(' php artisan env:manage save --project=项目名 --environment=环境名 # 保存环境');
$this->line(' php artisan env:manage import --project=项目名 --environment=环境名 # 导入环境');
$this->line(' php artisan env:manage delete --project=项目名 --environment=环境名 # 删除环境');
$this->line(' php artisan env:manage list # 列出所有项目');
$this->line(' php artisan env:manage environments --project=项目名 # 列出项目环境');
$this->line(' php artisan env:manage apply --project=项目名 --environment=环境名 # 应用环境');
$this->line(' php artisan env:manage save --project=项目名 --environment=环境名 # 保存环境');
$this->line(' php artisan env:manage import --project=项目名 --environment=环境名 # 导入环境');
$this->line(' php artisan env:manage delete --project=项目名 --environment=环境名 # 删除环境');
$this->line(' php artisan env:manage backups --project=项目名 # 列出项目备份');
$this->line(' php artisan env:manage restore --project=项目名 --backup=备份名 # 恢复备份');
$this->line(' php artisan env:manage delete-backup --project=项目名 --backup=备份名 # 删除备份');
}
/**

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class JiraTestCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:jira-test-command';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
//
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use Illuminate\View\View;
class AdminController extends Controller
{
/**
* 显示管理系统主页概览
*/
public function index(): View
{
return view('admin.index');
}
}

View File

@@ -15,13 +15,7 @@ class EnvController extends Controller
$this->envManager = $envManager;
}
/**
* 显示环境管理页面
*/
public function index()
{
return view('env.index');
}
/**
* 获取所有项目列表

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers;
use App\Services\JiraService;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class JiraController extends Controller
{
private JiraService $jiraService;
public function __construct(JiraService $jiraService)
{
$this->jiraService = $jiraService;
}
/**
* 生成上周周报
*/
public function generateWeeklyReport(Request $request): JsonResponse
{
try {
$username = $request->input('username') ?: config('jira.default_user');
if (!$username) {
return response()->json([
'success' => false,
'message' => '请提供用户名'
], 400);
}
$report = $this->jiraService->generateWeeklyReport($username);
return response()->json([
'success' => true,
'data' => [
'report' => $report,
'username' => $username,
'generated_at' => Carbon::now()->format('Y-m-d H:i:s')
]
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '生成周报失败: ' . $e->getMessage()
], 500);
}
}
/**
* 获取工时记录
*/
public function getWorkLogs(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
]);
try {
$username = $request->input('username');
$startDate = Carbon::parse($request->input('start_date'))->startOfDay();
$endDate = Carbon::parse($request->input('end_date'))->endOfDay();
$workLogs = $this->jiraService->getWorkLogs($username, $startDate, $endDate);
return response()->json([
'success' => true,
'data' => [
'work_logs' => $workLogs->values()->toArray(),
'total_hours' => $workLogs->sum('hours'),
'total_records' => $workLogs->count(),
'date_range' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d')
]
]
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '获取工时记录失败: ' . $e->getMessage()
], 500);
}
}
/**
* 获取JIRA配置信息
*/
public function getConfig(): JsonResponse
{
return response()->json([
'success' => true,
'data' => [
'default_user' => config('jira.default_user', ''),
'host' => config('jira.host', '')
]
]);
}
/**
* 下载周报文件
*/
public function downloadWeeklyReport(Request $request)
{
try {
$username = $request->input('username') ?: config('jira.default_user');
if (!$username) {
return response()->json([
'success' => false,
'message' => '请提供用户名'
], 400);
}
$report = $this->jiraService->generateWeeklyReport($username);
$filename = sprintf('weekly_report_%s_%s.md', $username, Carbon::now()->subWeek()->format('Y-m-d'));
return response($report)
->header('Content-Type', 'text/markdown')
->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '下载周报失败: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers;
use App\Services\MessageDispatchService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class MessageDispatchController extends Controller
{
private MessageDispatchService $messageDispatchService;
public function __construct(MessageDispatchService $messageDispatchService)
{
$this->messageDispatchService = $messageDispatchService;
}
/**
* 获取可用的服务列表
*/
public function getAvailableServices(): JsonResponse
{
try {
$services = $this->messageDispatchService->getAvailableServices();
return response()->json([
'success' => true,
'data' => $services,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '获取服务列表失败',
'error' => $e->getMessage()
], 500);
}
}
/**
* 获取可用的国家代码列表
*/
public function getAvailableCountryCodes(): JsonResponse
{
try {
$codes = $this->messageDispatchService->getAvailableCountryCodes();
return response()->json([
'success' => true,
'data' => $codes,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '获取国家代码列表失败',
'error' => $e->getMessage()
], 500);
}
}
/**
* 获取可用的域名列表
*/
public function getAvailableDomains(): JsonResponse
{
try {
$domains = $this->messageDispatchService->getAvailableDomains();
return response()->json([
'success' => true,
'data' => $domains,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '获取域名列表失败',
'error' => $e->getMessage()
], 500);
}
}
/**
* 查询异常的消息分发数据
*/
public function getAbnormalDispatches(Request $request): JsonResponse
{
try {
$request->validate([
'msg_ids' => 'nullable|array',
'msg_ids.*' => 'string',
'request_status' => 'nullable|integer',
'business_status' => 'nullable|integer',
'target_services' => 'nullable|array',
'target_services.*' => 'integer',
'country_codes' => 'nullable|array',
'domains' => 'nullable|array',
]);
$msgIds = $request->input('msg_ids');
$requestStatus = $request->input('request_status');
$businessStatus = $request->input('business_status');
$targetServices = $request->input('target_services');
$countryCodes = $request->input('country_codes');
$domains = $request->input('domains');
$results = $this->messageDispatchService->getAbnormalDispatches(
$msgIds,
$requestStatus,
$businessStatus,
$targetServices,
$countryCodes,
$domains
);
return response()->json([
'success' => true,
'data' => $results,
'total' => count($results),
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '查询异常消息失败: ' . $e->getMessage()
], 500);
}
}
/**
* 获取服务路由列表
*/
public function getServiceRoutes(): JsonResponse
{
try {
$routes = $this->messageDispatchService->getServiceRoutes();
return response()->json([
'success' => true,
'data' => $routes
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
}
/**
* 批量更新消息分发状态
*/
public function batchUpdateDispatch(Request $request): JsonResponse
{
try {
$request->validate([
'updates' => 'required|array|min:1',
'updates.*.id' => 'required',
'updates.*.request_status' => 'nullable|in:0,1,2,3,4,5',
'updates.*.business_status' => 'nullable|in:0,1,2',
'updates.*.retry_count' => 'nullable|numeric|min:0',
'updates.*.request_error_message' => 'nullable|string|max:1000',
'updates.*.request_error_code' => 'nullable|string|max:50',
'updates.*.business_error_message' => 'nullable|string|max:1000',
'updates.*.business_error_code' => 'nullable|string|max:50',
'updates.*.processing_time_ms' => 'nullable|numeric|min:0',
'updates.*.country_code' => 'nullable|string|max:10',
'updates.*.target_service' => 'nullable',
]);
$updates = $request->input('updates');
$results = $this->messageDispatchService->batchUpdateDispatch($updates);
$successCount = count(array_filter($results, fn($r) => $r['success']));
$failureCount = count($results) - $successCount;
return response()->json([
'success' => true,
'data' => [
'results' => $results,
'summary' => [
'total' => count($results),
'success' => $successCount,
'failure' => $failureCount,
]
]
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '批量更新失败: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,322 @@
<?php
namespace App\Http\Controllers;
use App\Services\MessageSyncService;
use App\Services\EventConsumerSyncService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon;
class MessageSyncController extends Controller
{
private MessageSyncService $messageSyncService;
private EventConsumerSyncService $eventConsumerSyncService;
public function __construct(
MessageSyncService $messageSyncService,
EventConsumerSyncService $eventConsumerSyncService
) {
$this->messageSyncService = $messageSyncService;
$this->eventConsumerSyncService = $eventConsumerSyncService;
}
/**
* 批量查询消息数据
*/
public function queryMessages(Request $request): JsonResponse
{
try {
$request->validate([
'message_ids' => 'required|array|min:1',
'message_ids.*' => 'required|string|max:255',
]);
$messageIds = array_filter(array_map('trim', $request->input('message_ids')));
if (empty($messageIds)) {
return response()->json([
'success' => false,
'message' => '请提供有效的消息ID列表'
], 400);
}
// 验证消息ID格式
$validationErrors = $this->messageSyncService->validateMessageIds($messageIds);
if (!empty($validationErrors)) {
return response()->json([
'success' => false,
'message' => '消息ID格式验证失败',
'errors' => $validationErrors
], 400);
}
// 查询消息数据
$messages = $this->messageSyncService->getMessagesByIds($messageIds);
$stats = $this->messageSyncService->getMessageStats($messageIds);
return response()->json([
'success' => true,
'data' => [
'messages' => $messages,
'stats' => $stats
]
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '查询消息失败: ' . $e->getMessage()
], 500);
}
}
/**
* 执行消息同步
*/
public function syncMessages(Request $request): JsonResponse
{
try {
$request->validate([
'message_ids' => 'required|array|min:1',
'message_ids.*' => 'required|string|max:255',
]);
$messageIds = array_filter(array_map('trim', $request->input('message_ids')));
if (empty($messageIds)) {
return response()->json([
'success' => false,
'message' => '请提供有效的消息ID列表'
], 400);
}
// 验证消息ID格式
$validationErrors = $this->messageSyncService->validateMessageIds($messageIds);
if (!empty($validationErrors)) {
return response()->json([
'success' => false,
'message' => '消息ID格式验证失败',
'errors' => $validationErrors
], 400);
}
// 执行同步
$results = $this->messageSyncService->syncMessages($messageIds);
// 统计同步结果
$successCount = count(array_filter($results, fn($r) => $r['success']));
$failureCount = count($results) - $successCount;
return response()->json([
'success' => true,
'data' => [
'results' => $results,
'summary' => [
'total' => count($results),
'success' => $successCount,
'failure' => $failureCount
]
]
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '消息同步失败: ' . $e->getMessage()
], 500);
}
}
/**
* 获取agent配置信息
*/
public function getAgentConfig(): JsonResponse
{
try {
$config = [
'agent_url' => config('services.agent.url'),
'timeout' => config('services.agent.timeout', 30),
];
return response()->json([
'success' => true,
'data' => $config
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '获取配置失败: ' . $e->getMessage()
], 500);
}
}
/**
* 测试数据库连接
*/
public function testConnection(): JsonResponse
{
try {
// 测试crmslave数据库连接
$connection = \DB::connection('crmslave');
$connection->getPdo();
// 测试表是否存在
$tableExists = $connection->getSchemaBuilder()->hasTable('system_publish_event');
return response()->json([
'success' => true,
'data' => [
'connection' => 'ok',
'table_exists' => $tableExists
]
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '数据库连接测试失败: ' . $e->getMessage()
], 500);
}
}
/**
* 对比CRM和Agent的事件消费者消息同步状态
*/
public function compareEventConsumerSync(Request $request): JsonResponse
{
try {
$request->validate([
'start_time' => 'nullable|date_format:Y-m-d H:i:s',
'end_time' => 'nullable|date_format:Y-m-d H:i:s',
'message_name' => 'nullable|string|max:255',
'exclude_messages' => 'nullable|array',
'exclude_messages.*' => 'string|max:255',
]);
$startTime = $request->input('start_time')
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('start_time'))
: null;
$endTime = $request->input('end_time')
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('end_time'))
: null;
$messageName = $request->input('message_name');
$excludeMessages = $request->input('exclude_messages', []);
$result = $this->eventConsumerSyncService->compareSyncStatus(
$startTime,
$endTime,
$excludeMessages,
$messageName
);
return response()->json([
'success' => true,
'data' => $result
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '对比消息同步状态失败: ' . $e->getMessage()
], 500);
}
}
/**
* 导出缺失的消息为Excel
*/
public function exportMissingMessages(Request $request): JsonResponse
{
try {
$request->validate([
'start_time' => 'nullable|date_format:Y-m-d H:i:s',
'end_time' => 'nullable|date_format:Y-m-d H:i:s',
'exclude_messages' => 'nullable|array',
'exclude_messages.*' => 'string|max:255',
]);
$startTime = $request->input('start_time')
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('start_time'))
: null;
$endTime = $request->input('end_time')
? Carbon::createFromFormat('Y-m-d H:i:s', $request->input('end_time'))
: null;
$excludeMessages = $request->input('exclude_messages', []);
$result = $this->eventConsumerSyncService->compareSyncStatus(
$startTime,
$endTime,
$excludeMessages
);
$missingMessages = $result['missing_messages'];
if (empty($missingMessages)) {
return response()->json([
'success' => true,
'message' => '没有缺失的消息',
'data' => []
]);
}
// 生成CSV数据
$csv = "msg_id,event_name,msg_body,created,updated\n";
foreach ($missingMessages as $msg) {
$csv .= sprintf(
'"%s","%s","%s","%s","%s"' . "\n",
$msg['msg_id'],
$msg['event_name'],
str_replace('"', '""', $msg['msg_body']),
$msg['created'],
$msg['updated']
);
}
return response()->json([
'success' => true,
'data' => [
'csv' => $csv,
'filename' => 'missing_messages_' . date('YmdHis') . '.csv',
'count' => count($missingMessages)
]
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '导出消息失败: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -11,7 +11,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// 注册 JIRA 服务
$this->app->singleton(\App\Services\JiraService::class);
}
/**

View File

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

View File

@@ -12,16 +12,23 @@ class EnvService
{
private string $projectsPath;
private string $envStoragePath;
private string $backupStoragePath;
public function __construct()
{
$this->projectsPath = '/var/www/Project';
$this->projectsPath = '/home/tradewind/Projects';
$this->envStoragePath = storage_path('app/env');
$this->backupStoragePath = storage_path('app/env/backups');
// 确保env存储目录存在
if (!File::exists($this->envStoragePath)) {
File::makeDirectory($this->envStoragePath, 0755, true);
}
// 确保备份存储目录存在
if (!File::exists($this->backupStoragePath)) {
File::makeDirectory($this->backupStoragePath, 0755, true);
}
}
/**
@@ -55,7 +62,7 @@ class EnvService
public function getProjectEnvs(string $projectName): array
{
$projectEnvPath = $this->envStoragePath . '/' . $projectName;
if (!File::exists($projectEnvPath)) {
return [];
}
@@ -85,13 +92,13 @@ class EnvService
{
$projectEnvPath = $this->envStoragePath . '/' . $projectName;
if (!File::exists($projectEnvPath)) {
File::makeDirectory($projectEnvPath, 0755, true);
}
$filePath = $projectEnvPath . '/' . $env . '.env';
return File::put($filePath, $content) !== false;
}
@@ -125,10 +132,9 @@ class EnvService
$envContent = $this->getEnvContent($projectName, $env);
$targetEnvPath = $projectPath . '/.env';
// 备份现有的.env文件
// 备份现有的.env文件到当前项目的storage目录
if (File::exists($targetEnvPath)) {
$backupPath = $targetEnvPath . '.backup.' . Carbon::now()->format('Y-m-d-H-i-s');
File::copy($targetEnvPath, $backupPath);
$this->backupCurrentEnv($projectName);
}
return File::put($targetEnvPath, $envContent) !== false;
@@ -172,7 +178,7 @@ class EnvService
{
$projectEnvPath = $this->projectsPath . '/' . $projectName . '/.env';
if (!File::exists($projectEnvPath)) {
return null;
}
@@ -221,4 +227,113 @@ class EnvService
return File::put($filePath, $defaultContent) !== false;
}
/**
* 备份项目当前的.env文件到storage目录
*/
private function backupCurrentEnv(string $projectName): bool
{
$projectPath = $this->projectsPath . '/' . $projectName;
$currentEnvPath = $projectPath . '/.env';
if (!File::exists($currentEnvPath)) {
return true; // 没有.env文件无需备份
}
// 创建项目备份目录
$projectBackupPath = $this->backupStoragePath . '/' . $projectName;
if (!File::exists($projectBackupPath)) {
File::makeDirectory($projectBackupPath, 0755, true);
}
// 生成备份文件名
$timestamp = Carbon::now()->format('Y-m-d-H-i-s');
$backupFileName = "env-backup-{$timestamp}.env";
$backupPath = $projectBackupPath . '/' . $backupFileName;
return File::copy($currentEnvPath, $backupPath);
}
/**
* 获取项目的所有备份文件
*/
public function getProjectBackups(string $projectName): array
{
$projectBackupPath = $this->backupStoragePath . '/' . $projectName;
if (!File::exists($projectBackupPath)) {
return [];
}
$backups = [];
$files = File::files($projectBackupPath);
foreach ($files as $file) {
if ($file->getExtension() === 'env' && str_starts_with($file->getFilename(), 'env-backup-')) {
$backups[] = [
'name' => $file->getFilenameWithoutExtension(),
'file_path' => $file->getPathname(),
'size' => $file->getSize(),
'created_at' => Carbon::createFromTimestamp($file->getMTime())->format('Y-m-d H:i:s')
];
}
}
// 按创建时间倒序排列
usort($backups, function ($a, $b) {
return strcmp($b['created_at'], $a['created_at']);
});
return $backups;
}
/**
* 恢复备份文件到项目
*/
public function restoreBackup(string $projectName, string $backupName): bool
{
$projectPath = $this->projectsPath . '/' . $projectName;
if (!File::exists($projectPath)) {
throw new InvalidArgumentException("项目不存在: {$projectName}");
}
$backupPath = $this->backupStoragePath . '/' . $projectName . '/' . $backupName . '.env';
if (!File::exists($backupPath)) {
throw new InvalidArgumentException("备份文件不存在: {$backupName}");
}
$targetEnvPath = $projectPath . '/.env';
// 在恢复前先备份当前的.env文件
if (File::exists($targetEnvPath)) {
$this->backupCurrentEnv($projectName);
}
$backupContent = File::get($backupPath);
return File::put($targetEnvPath, $backupContent) !== false;
}
/**
* 删除备份文件
*/
public function deleteBackup(string $projectName, string $backupName): bool
{
$backupPath = $this->backupStoragePath . '/' . $projectName . '/' . $backupName . '.env';
if (!File::exists($backupPath)) {
return true; // 文件不存在,视为删除成功
}
return File::delete($backupPath);
}
/**
* 获取备份存储路径
*/
public function getBackupStoragePath(): string
{
return $this->backupStoragePath;
}
}

View File

@@ -0,0 +1,271 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class EventConsumerSyncService
{
/**
* Agent监听的所有topic列表
*/
private const AGENT_LISTENED_TOPICS = [
// Case
'CASE_CREATE',
'CASE_FILE',
'CASE_3D_SEND',
'CASE_3D_CONFIRM',
'CASE_3D_CANCEL_CONFIRM',
'CASE_STAGE_FINISH',
'CASE_STAGE_CREATE',
'CASE_FINISH',
'CASE_PAUSE',
'CASE_DELETED',
'CASE_CONTINUE',
'CASE_MONEY_IN',
'CASE_BASIC_INFO_CHANGE',
'CASE_REFERRAL',
'CASE_TAGS_CHANGE',
'CASE_RECOVER',
'CASE_PRODUCT_WAIT_CONFIRM',
'CASE_PRODUCT_CONFIRM',
'CASE_PRODUCT_CANCEL_CONFIRM',
'CASE_START_DESIGN',
'CASE_NOT_TREATED',
'CASE_REOPEN',
'MEDICAL_DESIGN_EVENT',
// Production
'PRODUCTION_CREATE',
'PRODUCTION_DELIVER',
'SHIPPING_STATUS',
'PRODUCTION_INFO_CHANGE',
// Doctor
'DOCTOR_CREATE',
'DOCTOR_INFO_CHANGE',
'DOCTOR_STATUS_CHANGE',
'DOCTOR_DELETE',
// Hospital
'ACCOUNT_CREATE',
'ACCOUNT_INFO_CHANGE',
'ACCOUNT_AUTH_CHANGE',
'ACCOUNT_STATUS_CHANGE',
// BA
'BA_CREATE',
'BA_INFO_CHANGE',
// Business Document
'BUSINESS_ORDER_CREATE',
'BUSINESS_ORDER_DATA_CHANGE',
'BUSINESS_ORDER_STATUS_CHANGE',
'BUSINESS_ORDER_CHECK',
// Sale Document
'SALES_ORDER_CREATE',
'SALES_ORDER_DATA_CHANGE',
'SALES_ORDER_STATUS_CHANGE',
// Contract
'CONTRACT_CREATE',
'CONTRACT_INFO_CHANGE',
// Lead Hospital
'LEAD_HOSPITAL_CREATE',
'LEAD_HOSPITAL_CHANGE',
// Lead Doctor
'CREATE_LEADS',
'LEAD_UPDATE',
// GROUP
'GROUP_CREATE',
'GROUP_INFO_CHANGE',
// Hospital Enter
'ACCOUNT_ENTER_INFO_REJECT',
// Orthodontic
'APPLIANCE_CREATE',
'APPLIANCE_CHANGE',
'RETAINER_CHANGE',
'SET_MENU_CHANGE',
];
/**
* 查询CRM事件消费者表的消息数据
* 只查询Agent监听的topic
*/
public function getCrmEventConsumers(
?Carbon $startTime = null,
?Carbon $endTime = null,
array $excludeMessages = []
): array {
try {
$query = DB::connection('crmslave')
->table('crm_api.crm_event_consumer');
// 只查询Agent监听的topic
$query->whereIn('event_name', self::AGENT_LISTENED_TOPICS);
if ($startTime) {
$query->where('created', '>=', $startTime);
}
if ($endTime) {
$query->where('created', '<=', $endTime);
}
if (!empty($excludeMessages)) {
$query->whereNotIn('event_name', $excludeMessages);
}
$messages = $query->select([
'msg_id',
'event_name',
'msg_body',
'created',
'updated'
])->get();
return $messages->map(function ($msg) {
return [
'msg_id' => $msg->msg_id,
'event_name' => $msg->event_name,
'msg_body' => $msg->msg_body,
'created' => $msg->created,
'updated' => $msg->updated,
];
})->toArray();
} catch (\Exception $e) {
throw new \RuntimeException('查询CRM事件消费者表失败: ' . $e->getMessage());
}
}
/**
* 查询Agent事件消费者表的消息数据通过msg_id列表分批查询
* @param array $msgIds msg_id列表
* @param int $batchSize 每批查询的数量默认1000
*/
public function getAgentEventConsumersByMsgIds(array $msgIds, int $batchSize = 1000): array {
try {
if (empty($msgIds)) {
return [];
}
$messages = [];
// 分批查询,避免一次性查询过多数据导致慢查询
$batches = array_chunk($msgIds, $batchSize);
foreach ($batches as $batch) {
$batchMessages = DB::connection('agentslave')
->table('crm_event_consumer')
->whereIn('msg_id', $batch)
->select([
'msg_id',
'event_name',
'msg_body',
'created',
'updated'
])->get();
foreach ($batchMessages as $msg) {
$messages[] = [
'msg_id' => $msg->msg_id,
'event_name' => $msg->event_name,
'msg_body' => $msg->msg_body,
'created' => $msg->created,
'updated' => $msg->updated,
];
}
}
return $messages;
} catch (\Exception $e) {
throw new \RuntimeException('查询Agent事件消费者表失败: ' . $e->getMessage());
}
}
/**
* 对比CRM和Agent的消息找出缺失的消息
* 策略先查CRM的msg_id然后用这些msg_id到Agent中查询避免时间差异导致的缺失
*/
public function compareSyncStatus(
?Carbon $startTime = null,
?Carbon $endTime = null,
array $excludeMessages = [],
?string $messageName = null
): array {
// 1. 先查询CRM中的所有消息
$crmMessages = $this->getCrmEventConsumers($startTime, $endTime, $excludeMessages);
// 如果指定了消息名称,则进一步过滤
if ($messageName) {
$crmMessages = array_filter($crmMessages, function ($msg) use ($messageName) {
return $msg['event_name'] === $messageName;
});
}
$crmMsgIds = array_column($crmMessages, 'msg_id');
// 2. 用CRM的msg_id到Agent中查询不受时间限制
$agentMessages = $this->getAgentEventConsumersByMsgIds($crmMsgIds);
$agentMsgIds = array_column($agentMessages, 'msg_id');
// 3. 找出在CRM中但不在Agent中的消息
$missingMsgIds = array_diff($crmMsgIds, $agentMsgIds);
$missingMessages = array_filter($crmMessages, function ($msg) use ($missingMsgIds) {
return in_array($msg['msg_id'], $missingMsgIds);
});
// 4. 按topic统计缺失消息数量
$missingByTopic = $this->groupMissingMessagesByTopic($missingMessages);
return [
'crm_total' => count($crmMessages),
'agent_total' => count($agentMessages),
'missing_count' => count($missingMessages),
'sync_rate' => count($crmMessages) > 0
? round((count($crmMessages) - count($missingMessages)) / count($crmMessages) * 100, 2)
: 100,
'missing_messages' => array_values($missingMessages),
'missing_by_topic' => $missingByTopic,
'summary' => [
'start_time' => $startTime?->toDateTimeString(),
'end_time' => $endTime?->toDateTimeString(),
'message_name' => $messageName,
'excluded_messages' => $excludeMessages,
]
];
}
/**
* 获取Agent监听的所有topic列表
*/
public function getAgentListenedTopics(): array {
return self::AGENT_LISTENED_TOPICS;
}
/**
* 按topic统计缺失消息数量
*/
private function groupMissingMessagesByTopic(array $missingMessages): array {
$grouped = [];
foreach ($missingMessages as $msg) {
$topic = $msg['event_name'] ?? 'unknown';
if (!isset($grouped[$topic])) {
$grouped[$topic] = [
'topic' => $topic,
'count' => 0,
'messages' => []
];
}
$grouped[$topic]['count']++;
$grouped[$topic]['messages'][] = $msg['msg_id'];
}
// 按缺失数量降序排序
uasort($grouped, function ($a, $b) {
return $b['count'] - $a['count'];
});
return array_values($grouped);
}
}

View File

@@ -0,0 +1,742 @@
<?php
namespace App\Services;
use JiraRestApi\Configuration\ArrayConfiguration;
use JiraRestApi\Issue\IssueService;
use JiraRestApi\JiraException;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class JiraService
{
private IssueService $issueService;
private array $config;
public function __construct()
{
$this->config = config('jira');
$this->initializeJiraClient();
}
private function initializeJiraClient(): void
{
$jiraConfig = new ArrayConfiguration([
'jiraHost' => $this->config['host'],
'jiraUser' => $this->config['username'],
'jiraPassword' => $this->config['auth_type'] === 'token'
? $this->config['api_token']
: $this->config['password'],
'timeout' => $this->config['timeout'],
]);
$this->issueService = new IssueService($jiraConfig);
}
/**
* 按项目组织任务数据
*/
private function organizeIssuesByProject(array $issues): Collection
{
$organized = collect();
foreach ($issues as $issue) {
$projectKey = $issue->fields->project->key;
$isSubtask = $issue->fields->issuetype->subtask ?? false;
if (!$organized->has($projectKey)) {
$organized->put($projectKey, [
'name' => $issue->fields->project->name,
'tasks' => collect(),
]);
}
if ($isSubtask && isset($issue->fields->parent)) {
// 子任务
$parentKey = $issue->fields->parent->key;
$this->addSubtask($organized[$projectKey]['tasks'], $parentKey, $issue);
} else {
// 主任务
$this->addMainTask($organized[$projectKey]['tasks'], $issue);
}
}
return $organized;
}
/**
* 获取单个任务的详细信息
*/
private function getIssueDetails(string $issueKey): ?object
{
try {
return $this->issueService->get($issueKey, [
'summary',
'status',
'project',
'issuetype'
]);
} catch (JiraException) {
return null;
}
}
private function addMainTask(Collection $tasks, $issue): void
{
$tasks->put($issue->key, [
'key' => $issue->key,
'summary' => $issue->fields->summary,
'url' => $this->config['host'] . '/browse/' . $issue->key,
'subtasks' => collect(),
]);
}
private function addSubtask(Collection $tasks, string $parentKey, $issue): void
{
if (!$tasks->has($parentKey)) {
// 获取父任务的真实信息
$parentDetails = $this->getIssueDetails($parentKey);
$parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务';
$tasks->put($parentKey, [
'key' => $parentKey,
'summary' => $parentSummary,
'url' => $this->config['host'] . '/browse/' . $parentKey,
'subtasks' => collect(),
]);
}
$tasks[$parentKey]['subtasks']->put($issue->key, [
'key' => $issue->key,
'summary' => $issue->fields->summary,
'url' => $this->config['host'] . '/browse/' . $issue->key,
'created' => $issue->fields->created ?? null,
]);
}
/**
* 获取未来一周的任务
*/
public function getNextWeekTasks(?string $username = null): Collection
{
$username = $username ?: $this->config['default_user'];
if (!$username) {
throw new \InvalidArgumentException('用户名不能为空');
}
// 查询分配给用户且未完成的任务(不包括子任务)
$jql = sprintf(
'assignee = "%s" AND status != "Done" AND issuetype != "Sub-task" ORDER BY created ASC',
$username
);
try {
$issues = $this->issueService->search($jql, 0, 50, [
'summary',
'status',
'project',
'issuetype',
'created'
]);
if (!empty($issues->issues)) {
return $this->organizeIssuesByProject($issues->issues);
}
} catch (JiraException $e) {
throw new \RuntimeException('获取未来任务失败: ' . $e->getMessage());
}
return collect();
}
/**
* 生成 Markdown 格式的周报
*/
public function generateWeeklyReport(?string $username = null): string
{
$username = $username ?: $this->config['default_user'];
// 获取上周的工时记录
$now = Carbon::now();
$startOfWeek = $now->copy()->subWeek()->startOfWeek();
$endOfWeek = $now->copy()->subWeek()->endOfWeek();
$workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek);
$organizedTasks = $this->organizeTasksForReport($workLogs);
$nextWeekTasks = $this->getNextWeekTasks($username);
$markdown = "# 过去一周的任务\n\n";
if ($organizedTasks->isEmpty()) {
$markdown .= "本周暂无工时记录的任务。\n\n";
} else {
// 按Sprint分类的需求
if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) {
foreach ($organizedTasks['sprints'] as $sprintName => $tasks) {
$markdown .= "### {$sprintName}\n";
foreach ($tasks 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";
foreach ($organizedTasks['tasks'] 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";
}
// 按发现阶段分类的Bug
if ($organizedTasks->has('bugs') && $organizedTasks['bugs']->isNotEmpty()) {
foreach ($organizedTasks['bugs'] as $stage => $bugs) {
$markdown .= "### {$stage}\n";
// 按父任务分组Bug
$groupedBugs = collect($bugs)->groupBy(function ($bug) {
return $bug['parent_key'] ?? 'standalone';
});
foreach ($groupedBugs as $parentKey => $bugGroup) {
if ($parentKey === 'standalone') {
// 独立的Bug
foreach ($bugGroup as $bug) {
$checkbox = $this->isTaskCompleted($bug['status']) ? '[x]' : '[ ]';
$summary = $this->cleanSummary($bug['summary']);
// 只标记非代码错误的Bug类型并附加修复描述
$bugTypeLabel = '';
if ($bug['bug_type'] && $bug['bug_type'] !== '代码错误') {
$bugTypeLabel = "\n - {$bug['bug_type']}";
if ($bug['bug_description']) {
$bugTypeLabel .= "{$bug['bug_description']}";
}
}
$markdown .= "- {$checkbox} [{$bug['key']}]({$bug['url']}) {$summary}{$bugTypeLabel}\n";
}
} else {
// 有父任务的Bug
$firstBug = $bugGroup->first();
$markdown .= "- [x] {$firstBug['parent_summary']}\n";
foreach ($bugGroup as $bug) {
$checkbox = $this->isTaskCompleted($bug['status']) ? '[x]' : '[ ]';
$summary = $this->cleanSummary($bug['summary']);
// 只标记非代码错误的Bug类型并附加修复描述
$bugTypeLabel = '';
if ($bug['bug_type'] && $bug['bug_type'] !== '代码错误') {
$bugTypeLabel = "\n - {$bug['bug_type']}";
if ($bug['bug_description']) {
$bugTypeLabel .= "{$bug['bug_description']}";
}
}
$markdown .= " - {$checkbox} [{$bug['key']}]({$bug['url']}) {$summary}{$bugTypeLabel}\n";
}
}
}
$markdown .= "\n";
}
}
}
$markdown .= "\n# 未来一周的任务\n\n";
foreach ($nextWeekTasks as $project) {
foreach ($project['tasks'] as $task) {
$markdown .= "- [ ] [{$task['key']}]({$task['url']}) {$task['summary']}\n";
}
}
return $markdown;
}
/**
* 获取指定日期范围内的工时记录
*/
public function getWorkLogs(string $username, Carbon $startDate, Carbon $endDate): Collection
{
// 标准工时查询 - 注意某些JIRA版本可能不支持worklogAuthor和worklogDate
$jql = sprintf(
'worklogAuthor = "%s" AND worklogDate >= "%s" AND worklogDate <= "%s" ORDER BY updated DESC',
$username,
$startDate->format('Y-m-d'),
$endDate->format('Y-m-d')
);
try {
$issues = $this->issueService->search($jql, 0, 100, [
'summary',
'project',
'worklog',
'parent',
'issuetype',
'status',
'created',
'fixVersions',
'labels',
'customfield_10004', // Sprint字段
'customfield_10900', // Bug发现阶段
'customfield_12700', // Bug错误类型
'customfield_10115', // Bug修复描述
'customfield_14305', // 需求类型
]);
if (!empty($issues->issues)) {
$workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate);
if ($workLogs->isNotEmpty()) {
return $workLogs;
}
}
} catch (JiraException $e) {
throw new \RuntimeException('获取工时记录失败: ' . $e->getMessage());
}
// 如果所有查询都没有结果,返回空集合
return collect();
}
/**
* 提取工时记录
*/
private function extractWorkLogs(array $issues, string $username, Carbon $startDate, Carbon $endDate): Collection
{
$workLogs = collect();
foreach ($issues as $issue) {
try {
$worklogData = $this->issueService->getWorklog($issue->key);
foreach ($worklogData->worklogs as $worklog) {
try {
$worklogDate = Carbon::parse($worklog->started);
// 处理 author 可能是数组或对象的情况
$authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? '');
if (!empty($authorName) && $authorName === $username &&
$worklogDate->between($startDate, $endDate)) {
// 获取父任务信息
$parentTask = null;
if (isset($issue->fields->parent)) {
$parentTask = [
'key' => $issue->fields->parent->key ?? '',
'summary' => $issue->fields->parent->fields->summary ?? '',
];
}
// 提取Sprint信息
$sprint = $this->extractSprintInfo($issue);
// 提取Bug相关信息
$bugStage = $this->extractBugStage($issue);
$bugType = $this->extractBugType($issue);
$bugDescription = $this->extractBugDescription($issue);
// 提取需求类型
$requirementType = $this->extractRequirementType($issue);
$workLogs->push([
'id' => $worklog->id ?? '',
'project' => $issue->fields->project->name ?? '',
'project_key' => $issue->fields->project->key ?? '',
'issue_key' => $issue->key,
'issue_summary' => $issue->fields->summary ?? '',
'issue_url' => $this->config['host'] . '/browse/' . $issue->key,
'issue_status' => $issue->fields->status->name ?? 'Unknown',
'issue_type' => $issue->fields->issuetype->name ?? 'Unknown',
'issue_created' => $issue->fields->created ?? null,
'parent_task' => $parentTask,
'sprint' => $sprint,
'bug_stage' => $bugStage,
'bug_type' => $bugType,
'bug_description' => $bugDescription,
'requirement_type' => $requirementType,
'date' => $worklogDate->format('Y-m-d'),
'time' => $worklogDate->format('H:i'),
'hours' => round(($worklog->timeSpentSeconds ?? 0) / 3600, 2),
'time_spent_seconds' => $worklog->timeSpentSeconds ?? 0,
'time_spent' => $worklog->timeSpent ?? '',
'comment' => $worklog->comment ?? '',
'author' => [
'name' => $authorName,
'display_name' => is_array($worklog->author) ? ($worklog->author['displayName'] ?? '') : ($worklog->author->displayName ?? ''),
'email' => is_array($worklog->author) ? ($worklog->author['emailAddress'] ?? '') : ($worklog->author->emailAddress ?? ''),
],
'created' => isset($worklog->created) ? Carbon::parse($worklog->created)->format('Y-m-d H:i:s') : '',
'updated' => isset($worklog->updated) ? Carbon::parse($worklog->updated)->format('Y-m-d H:i:s') : '',
'started' => $worklogDate->format('Y-m-d H:i:s'),
]);
}
} catch (\Exception) {
// 跳过有问题的工时记录
continue;
}
}
} catch (JiraException) {
// 跳过无法获取工时记录的任务
continue;
}
}
return $workLogs->sortByDesc('date');
}
/**
* 提取Sprint信息
*/
private function extractSprintInfo($issue): ?string
{
// 尝试从customfield_10004获取Sprint信息
if (isset($issue->fields->customFields['customfield_10004'])) {
$sprintField = $issue->fields->customFields['customfield_10004'];
// 处理数组情况
if (is_array($sprintField) && !empty($sprintField)) {
$lastSprint = end($sprintField);
if (is_string($lastSprint)) {
// 解析Sprint字符串格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...]
if (preg_match('/name=([^,\]]+)/', $lastSprint, $matches)) {
return $matches[1];
}
} elseif (is_object($lastSprint) && isset($lastSprint->name)) {
return $lastSprint->name;
}
}
// 处理对象情况
if (is_object($sprintField) && isset($sprintField->name)) {
return $sprintField->name;
}
// 处理字符串情况
if (is_string($sprintField)) {
if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) {
return $matches[1];
}
// 如果是纯文本,直接返回
return $sprintField;
}
}
// 尝试从fixVersions获取版本信息作为备选
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && !empty($issue->fields->fixVersions)) {
return $issue->fields->fixVersions[0]->name ?? null;
}
// 尝试从summary中提取Sprint信息如果summary包含【十月中需求】这样的标记
if (isset($issue->fields->summary)) {
$summary = $issue->fields->summary;
// 匹配【xxx需求】或【xxx】格式
if (preg_match('/【([^】]*需求)】/', $summary, $matches)) {
return $matches[1];
}
}
return null;
}
/**
* 提取Bug发现阶段
*/
private function extractBugStage($issue): ?string
{
// 从customfield_10900获取Bug阶段
if (isset($issue->fields->customFields['customfield_10900'])) {
$stage = $issue->fields->customFields['customfield_10900'];
// 处理对象类型
if (is_object($stage) && isset($stage->value)) {
$stageValue = $stage->value;
} elseif (is_string($stage)) {
$stageValue = $stage;
} else {
$stageValue = null;
}
if ($stageValue && !empty($stageValue)) {
// 标准化阶段名称
if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') {
return 'SIT环境BUG';
}
if (str_contains($stageValue, '生产') || str_contains($stageValue, 'PROD') || str_contains($stageValue, 'prod') || $stageValue === '生产环境') {
return '生产环境BUG';
}
if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) {
return 'UAT环境BUG';
}
// 如果不匹配标准格式,直接返回原值
return $stageValue . 'BUG';
}
}
// 从labels中提取Bug阶段
if (isset($issue->fields->labels) && is_array($issue->fields->labels)) {
foreach ($issue->fields->labels as $label) {
if (str_contains($label, 'SIT') || str_contains($label, 'sit')) {
return 'SIT环境BUG';
}
if (str_contains($label, '生产') || str_contains($label, 'PROD') || str_contains($label, 'prod')) {
return '生产环境BUG';
}
if (str_contains($label, 'UAT') || str_contains($label, 'uat')) {
return 'UAT环境BUG';
}
}
}
return null;
}
/**
* 提取Bug错误类型
*/
private function extractBugType($issue): ?string
{
// 从customfield_12700获取Bug类型
if (isset($issue->fields->customFields['customfield_12700'])) {
$type = $issue->fields->customFields['customfield_12700'];
// 处理对象类型
if (is_object($type) && isset($type->value)) {
return $type->value;
} elseif (is_string($type) && !empty($type)) {
return $type;
}
}
// 从labels中提取Bug类型
if (isset($issue->fields->labels) && is_array($issue->fields->labels)) {
$bugTypes = ['需求未说明', '沟通问题', '接口文档未说明', '数据问题', '配置问题', '环境问题'];
foreach ($issue->fields->labels as $label) {
foreach ($bugTypes as $bugType) {
if (str_contains($label, $bugType)) {
return $bugType;
}
}
}
}
return null;
}
/**
* 提取Bug修复描述
*/
private function extractBugDescription($issue): ?string
{
// 从customfield_10115获取Bug修复描述
if (isset($issue->fields->customFields['customfield_10115'])) {
$description = $issue->fields->customFields['customfield_10115'];
if (is_string($description) && !empty($description)) {
return $description;
}
}
return null;
}
/**
* 提取需求类型
*/
private function extractRequirementType($issue): ?string
{
// 从customfield_14305获取需求类型
if (isset($issue->fields->customFields['customfield_14305'])) {
$type = $issue->fields->customFields['customfield_14305'];
if (is_array($type) && !empty($type)) {
$firstType = $type[0];
if (is_object($firstType) && isset($firstType->value)) {
return $firstType->value;
}
} elseif (is_object($type) && isset($type->value)) {
return $type->value;
} elseif (is_string($type)) {
return $type;
}
}
return null;
}
/**
* 清理摘要中的图片链接
*/
private function cleanSummary(string $summary): string
{
// 移除 Jira 图片标记 !image-xxx.png! 和 !https://xxx.png!
$summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary);
// 移除多余的空格和换行
$summary = preg_replace('/\s+/', ' ', $summary);
return trim($summary);
}
/**
* 判断任务状态是否应该标记为完成
*/
private function isTaskCompleted(string $status): bool
{
// 不打勾的状态(未完成状态)
$incompleteStatuses = [
'开发中',
'需求已排期',
'需求已评审',
'In Progress',
'To Do',
'Open',
'Reopened',
'In Review',
'Code Review',
'Testing',
'Ready for Testing'
];
return !in_array($status, $incompleteStatuses, true);
}
/**
* 组织任务数据用于周报生成
*/
private function organizeTasksForReport(Collection $workLogs): Collection
{
$organized = collect([
'sprints' => collect(),
'tasks' => collect(),
'bugs' => collect(),
]);
$processedIssues = collect();
foreach ($workLogs as $workLog) {
$issueKey = $workLog['issue_key'];
$issueType = $workLog['issue_type'] ?? 'Unknown';
// 避免重复处理同一个任务
if ($processedIssues->has($issueKey)) {
continue;
}
$processedIssues->put($issueKey, true);
// 判断是否为Bug通过issuetype判断
$isBug = in_array($issueType, ['Bug', 'BUG', 'bug', '缺陷', 'Defect']);
// 判断是否为需求Story类型
$isStory = in_array($issueType, ['Story', 'story', '需求']);
// 判断是否为子任务
$isSubtask = in_array($issueType, ['Sub-task', 'sub-task', '子任务']);
if ($isBug && $workLog['bug_stage']) {
// Bug按发现阶段分类
$stage = $workLog['bug_stage'];
if (!$organized['bugs']->has($stage)) {
$organized['bugs']->put($stage, collect());
}
$bugData = [
'key' => $workLog['issue_key'],
'summary' => $workLog['issue_summary'],
'url' => $workLog['issue_url'],
'status' => $workLog['issue_status'],
'bug_type' => $workLog['bug_type'],
'bug_description' => $workLog['bug_description'],
];
// 如果有父任务,添加父任务信息
if ($workLog['parent_task']) {
$bugData['parent_key'] = $workLog['parent_task']['key'];
$bugData['parent_summary'] = $workLog['parent_task']['summary'];
}
$organized['bugs'][$stage]->push($bugData);
} elseif (($isStory || $isSubtask) && $workLog['sprint']) {
// Story类型或子任务且有Sprint的按Sprint分类需求
$sprintName = $workLog['sprint'];
if (!$organized['sprints']->has($sprintName)) {
$organized['sprints']->put($sprintName, collect());
}
$this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog);
} else {
// 其他任务单独列出非Story/子任务类型或没有Sprint的
$this->addTaskToSprintOrTaskList($organized['tasks'], $workLog);
}
}
return $organized;
}
/**
* 添加任务到Sprint或任务列表
*/
private function addTaskToSprintOrTaskList(Collection $taskList, array $workLog): void
{
$status = $workLog['issue_status'] ?? 'Unknown';
if ($workLog['parent_task']) {
// 子任务
$parentKey = $workLog['parent_task']['key'];
if (!$taskList->has($parentKey)) {
// 获取父任务的真实信息
$parentDetails = $this->getIssueDetails($parentKey);
$parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary'];
$parentStatus = $parentDetails ? $parentDetails->fields->status->name : 'Unknown';
$taskList->put($parentKey, [
'key' => $parentKey,
'summary' => $parentSummary,
'url' => $this->config['host'] . '/browse/' . $parentKey,
'status' => $parentStatus,
'subtasks' => collect(),
]);
}
$taskList[$parentKey]['subtasks']->put($workLog['issue_key'], [
'key' => $workLog['issue_key'],
'summary' => $workLog['issue_summary'],
'url' => $workLog['issue_url'],
'status' => $status,
'created' => $workLog['issue_created'],
]);
} else {
// 主任务
if (!$taskList->has($workLog['issue_key'])) {
$taskList->put($workLog['issue_key'], [
'key' => $workLog['issue_key'],
'summary' => $workLog['issue_summary'],
'url' => $workLog['issue_url'],
'status' => $status,
'subtasks' => collect(),
]);
}
}
}
}

View File

@@ -0,0 +1,349 @@
<?php
namespace App\Services;
use App\Clients\MonoClient;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MessageDispatchService
{
private MonoClient $monoClient;
public function __construct(MonoClient $monoClient)
{
$this->monoClient = $monoClient;
}
/**
* 查询异常的消息分发数据
*/
public function getAbnormalDispatches(
?array $msgIds = null,
?int $requestStatus = null,
?int $businessStatus = null,
?array $targetServices = null,
?array $countryCodes = null,
?array $domains = null
): array
{
try {
$query = DB::connection('monoslave')
->table('message_dispatch as md')
->join('message_consumer as mc', 'mc.msg_id', '=', 'md.msg_id')
->leftJoin('service_routes as sr', 'md.target_service', '=', 'sr.id')
->where('md.request_status', '<>', 5)
->where(function ($q) {
$q->where('md.request_status', '<>', 1)
->orWhere('md.business_status', '<>', 1);
})
->where('md.created', '<', Carbon::now()->subMinutes(5));
// 筛选条件
if ($msgIds && count($msgIds) > 0) {
$query->whereIn('md.msg_id', $msgIds);
}
if ($requestStatus !== null) {
$query->where('md.request_status', $requestStatus);
}
if ($businessStatus !== null) {
$query->where('md.business_status', $businessStatus);
}
if ($targetServices && count($targetServices) > 0) {
$query->whereIn('md.target_service', $targetServices);
}
if ($countryCodes && count($countryCodes) > 0) {
$query->where(function ($q) use ($countryCodes) {
$hasNull = in_array('', $countryCodes) || in_array(null, $countryCodes);
$nonNullCodes = array_filter($countryCodes, fn($c) => $c !== '' && $c !== null);
if ($hasNull) {
$q->whereNull('sr.country_code');
if (count($nonNullCodes) > 0) {
$q->orWhereIn('sr.country_code', $nonNullCodes);
}
} else {
$q->whereIn('sr.country_code', $nonNullCodes);
}
});
}
if ($domains && count($domains) > 0) {
$query->where(function ($q) use ($domains) {
$hasNull = in_array('', $domains) || in_array(null, $domains);
$nonNullDomains = array_filter($domains, fn($d) => $d !== '' && $d !== null);
if ($hasNull) {
$q->whereNull('sr.service_endpoint');
if (count($nonNullDomains) > 0) {
$q->orWhere(function ($subQ) use ($nonNullDomains) {
foreach ($nonNullDomains as $domain) {
$subQ->orWhere('sr.service_endpoint', 'like', "%{$domain}%");
}
});
}
} else {
$q->where(function ($subQ) use ($nonNullDomains) {
foreach ($nonNullDomains as $domain) {
$subQ->orWhere('sr.service_endpoint', 'like', "%{$domain}%");
}
});
}
});
}
$results = $query->select([
'md.id',
'md.msg_id',
'md.target_service',
'md.request_status',
'md.business_status',
'md.retry_count',
'md.request_error_message',
'md.business_error_message',
'mc.event_name',
'mc.entity_code',
DB::raw("mc.msg_body->>'$.data.delAccountList' as delAccountList"),
DB::raw("mc.msg_body->>'$.data.afterStatus' as afterStatus"),
'mc.msg_body',
'md.created',
'md.updated',
'sr.country_code',
'sr.service_endpoint'
])->get();
// 获取所有msg_id排除US域名的消息
$nonUsMsgIds = $results->filter(function ($item) {
$domain = $item->service_endpoint ? (parse_url($item->service_endpoint, PHP_URL_HOST) ?? $item->service_endpoint) : null;
return $domain !== 'partner-us.eainc.com';
})->pluck('msg_id')->unique()->toArray();
// 从Agent库查询consumer状态仅非US域名
$consumerStatuses = $this->getConsumerStatuses($nonUsMsgIds);
return $results->map(function ($item) use ($consumerStatuses) {
$domain = $item->service_endpoint ? (parse_url($item->service_endpoint, PHP_URL_HOST) ?? $item->service_endpoint) : null;
$isUsDomain = $domain === 'partner-us.eainc.com';
return [
'id' => $item->id,
'msg_id' => $item->msg_id,
'target_service' => $item->target_service,
'country_code' => $item->country_code,
'domain' => $domain,
'request_status' => $item->request_status,
'business_status' => $item->business_status,
'retry_count' => $item->retry_count,
'request_error_message' => $item->request_error_message,
'request_error_code' => $item->request_error_code ?? null,
'business_error_message' => $item->business_error_message,
'business_error_code' => $item->business_error_code ?? null,
'event_name' => $item->event_name,
'entity_code' => $item->entity_code,
'delAccountList' => $item->delAccountList,
'afterStatus' => $item->afterStatus,
'msg_body' => $item->msg_body,
'created' => $item->created,
'updated' => $item->updated,
'consumer_status' => $isUsDomain ? null : ($consumerStatuses[$item->msg_id] ?? null),
];
})->toArray();
} catch (\Exception $e) {
throw new \RuntimeException('查询异常消息分发数据失败: ' . $e->getMessage());
}
}
/**
* 从Agent库查询消费者状态
*/
private function getConsumerStatuses(array $msgIds): array
{
if (empty($msgIds)) {
return [];
}
try {
$consumers = DB::connection('agentslave')
->table('crm_event_consumer')
->whereIn('msg_id', $msgIds)
->select(['msg_id', 'status'])
->get();
$statuses = [];
foreach ($consumers as $consumer) {
$statuses[$consumer->msg_id] = $consumer->status;
}
return $statuses;
} catch (\Exception $e) {
return [];
}
}
/**
* 格式化服务名称country_code(域名)
*/
private function formatServiceName(?string $countryCode, ?string $serviceEndpoint): string
{
if (!$countryCode || !$serviceEndpoint) {
return 'Unknown';
}
// 从URL中提取域名
$domain = parse_url($serviceEndpoint, PHP_URL_HOST) ?? $serviceEndpoint;
return "{$countryCode}({$domain})";
}
/**
* 获取所有可用的服务列表
*/
public function getAvailableServices(): array
{
try {
$services = DB::connection('monoslave')
->table('service_routes')
->select(['id', 'country_code', 'service_endpoint'])
->get();
return $services->map(function ($service) {
return [
'id' => $service->id,
'name' => $this->formatServiceName($service->country_code, $service->service_endpoint),
];
})->toArray();
} catch (\Exception $e) {
return [];
}
}
/**
* 获取所有国家代码列表
*/
public function getAvailableCountryCodes(): array
{
try {
$codes = DB::connection('monoslave')
->table('service_routes')
->select('country_code')
->distinct()
->orderBy('country_code')
->get()
->pluck('country_code')
->filter()
->values()
->toArray();
return $codes;
} catch (\Exception $e) {
return [];
}
}
/**
* 获取所有域名列表
*/
public function getAvailableDomains(): array
{
try {
$endpoints = DB::connection('monoslave')
->table('service_routes')
->select('service_endpoint')
->distinct()
->get()
->pluck('service_endpoint')
->filter()
->map(function ($endpoint) {
return parse_url($endpoint, PHP_URL_HOST) ?? $endpoint;
})
->unique()
->values()
->toArray();
return $endpoints;
} catch (\Exception $e) {
return [];
}
}
/**
* 获取所有服务路由列表
*/
public function getServiceRoutes(): array
{
try {
$routes = DB::connection('monoslave')
->table('service_routes')
->select('id', 'country_code', 'service_endpoint')
->orderBy('country_code')
->orderBy('id')
->get()
->map(function ($route) {
$domain = parse_url($route->service_endpoint, PHP_URL_HOST) ?? $route->service_endpoint;
return [
'id' => $route->id,
'country_code' => $route->country_code,
'domain' => $domain,
'display_name' => ($route->country_code ?: '(空)') . ' - ' . $domain,
];
})
->toArray();
return $routes;
} catch (\Exception $e) {
return [];
}
}
/**
* 批量更新消息分发状态
*/
public function batchUpdateDispatch(array $updates): array
{
$results = [];
foreach ($updates as $update) {
try {
$result = $this->updateDispatch($update);
$results[] = [
'id' => $update['id'],
'success' => $result['success'],
'message' => $result['message'] ?? null,
];
} catch (\Exception $e) {
$results[] = [
'id' => $update['id'],
'success' => false,
'message' => $e->getMessage(),
];
}
}
return $results;
}
/**
* 更新单个消息分发状态
*/
private function updateDispatch(array $data): array
{
$response = $this->monoClient->updateDispatch($data);
if ($response->successful()) {
return [
'success' => true,
'data' => $response->json(),
];
}
return [
'success' => false,
'message' => 'HTTP ' . $response->status() . ': ' . $response->body(),
];
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Services;
use App\Clients\AgentClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
use Carbon\Carbon;
class MessageSyncService
{
private AgentClient $agentClient;
public function __construct(AgentClient $agentClient)
{
$this->agentClient = $agentClient;
}
/**
* 根据消息ID列表从crmslave数据库获取消息数据
*/
public function getMessagesByIds(array $messageIds): Collection
{
if (empty($messageIds)) {
return collect();
}
try {
$messages = DB::connection('crmslave')
->table('system_publish_event')
->whereIn('msg_id', $messageIds)
->select([
'msg_id',
'event_type',
'trace_id',
'event_param',
'event_property',
'timestamp'
])
->get();
return $messages->map(function ($message) {
return [
'msg_id' => $message->msg_id,
'event_type' => $message->event_type,
'trace_id' => $message->trace_id,
'event_param' => $message->event_param,
'event_property' => $message->event_property,
'timestamp' => $message->timestamp,
'parsed_param' => $this->parseJsonField($message->event_param),
'parsed_property' => $this->parseJsonField($message->event_property),
];
});
} catch (\Exception $e) {
throw new \RuntimeException('从crmslave数据库获取消息失败: ' . $e->getMessage());
}
}
/**
* 批量同步消息到agent
*/
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,
];
}
return $results;
}
/**
* 同步单个消息到agent
*/
private function syncSingleMessage(array $message): array
{
try {
$requestData = $this->buildAgentRequest($message);
$response = $this->agentClient->dispatchMessage($requestData);
if ($response->successful()) {
return [
'success' => true,
'response' => $response->json(),
'request_data' => $requestData,
];
} else {
return [
'success' => false,
'error' => 'HTTP ' . $response->status() . ': ' . $response->body(),
'request_data' => $requestData,
];
}
} catch (\Exception $e) {
return [
'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字段
*/
private function parseJsonField(?string $jsonString): mixed
{
if (empty($jsonString)) {
return null;
}
try {
return json_decode($jsonString, true);
} catch (\Exception $e) {
return $jsonString; // 如果解析失败,返回原始字符串
}
}
/**
* 验证消息ID格式
*/
public function validateMessageIds(array $messageIds): array
{
$errors = [];
foreach ($messageIds as $index => $messageId) {
if (empty($messageId)) {
$errors[] = "" . ($index + 1) . " 行: 消息ID不能为空";
continue;
}
if (!is_string($messageId) && !is_numeric($messageId)) {
$errors[] = "" . ($index + 1) . " 行: 消息ID格式无效";
continue;
}
// 可以添加更多的格式验证规则
}
return $errors;
}
/**
* 获取消息统计信息
*/
public function getMessageStats(array $messageIds): array
{
$messages = $this->getMessagesByIds($messageIds);
$stats = [
'total_requested' => count($messageIds),
'total_found' => $messages->count(),
'total_missing' => count($messageIds) - $messages->count(),
'event_types' => $messages->groupBy('event_type')->map->count(),
'missing_ids' => array_diff($messageIds, $messages->pluck('msg_id')->toArray()),
];
return $stats;
}
}