diff --git a/.env.example b/.env.example
index 35db1dd..ef8f534 100644
--- a/.env.example
+++ b/.env.example
@@ -63,3 +63,41 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+
+# JIRA Configuration
+JIRA_HOST=http://jira.eainc.com:8080
+JIRA_USERNAME=
+JIRA_PASSWORD=
+JIRA_API_TOKEN=
+JIRA_AUTH_TYPE=basic
+JIRA_TIMEOUT=30
+JIRA_DEFAULT_USER=
+
+# CRM Slave Database Configuration
+CRMSLAVE_DB_HOST=127.0.0.1
+CRMSLAVE_DB_PORT=3306
+CRMSLAVE_DB_DATABASE=crmslave
+CRMSLAVE_DB_USERNAME=root
+CRMSLAVE_DB_PASSWORD=
+
+# Agent Slave Database Configuration
+AGENTSLAVE_DB_HOST=127.0.0.1
+AGENTSLAVE_DB_PORT=3306
+AGENTSLAVE_DB_DATABASE=agent
+AGENTSLAVE_DB_USERNAME=root
+AGENTSLAVE_DB_PASSWORD=
+
+# Mono Slave Database Configuration
+MONOSLAVE_DB_HOST=127.0.0.1
+MONOSLAVE_DB_PORT=3306
+MONOSLAVE_DB_DATABASE=mono
+MONOSLAVE_DB_USERNAME=root
+MONOSLAVE_DB_PASSWORD=
+
+# Agent Service Configuration
+AGENT_URL=http://localhost:8080
+AGENT_TIMEOUT=30
+
+# Mono Service Configuration
+MONO_URL=http://localhost:8081
+MONO_TIMEOUT=30
diff --git a/app/Clients/AgentClient.php b/app/Clients/AgentClient.php
new file mode 100644
index 0000000..c5e1390
--- /dev/null
+++ b/app/Clients/AgentClient.php
@@ -0,0 +1,37 @@
+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');
+ }
+}
+
diff --git a/app/Clients/MonoClient.php b/app/Clients/MonoClient.php
new file mode 100644
index 0000000..5bb1c8e
--- /dev/null
+++ b/app/Clients/MonoClient.php
@@ -0,0 +1,37 @@
+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);
+ }
+}
+
diff --git a/app/Console/Commands/EnvCommand.php b/app/Console/Commands/EnvCommand.php
index 12e1667..abf8d27 100644
--- a/app/Console/Commands/EnvCommand.php
+++ b/app/Console/Commands/EnvCommand.php
@@ -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=备份名 # 删除备份');
}
/**
diff --git a/app/Console/Commands/JiraTestCommand.php b/app/Console/Commands/JiraTestCommand.php
new file mode 100644
index 0000000..3356e1c
--- /dev/null
+++ b/app/Console/Commands/JiraTestCommand.php
@@ -0,0 +1,30 @@
+envManager = $envManager;
}
- /**
- * 显示环境管理页面
- */
- public function index()
- {
- return view('env.index');
- }
+
/**
* 获取所有项目列表
diff --git a/app/Http/Controllers/JiraController.php b/app/Http/Controllers/JiraController.php
new file mode 100644
index 0000000..ffac2af
--- /dev/null
+++ b/app/Http/Controllers/JiraController.php
@@ -0,0 +1,135 @@
+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);
+ }
+ }
+
+}
diff --git a/app/Http/Controllers/MessageDispatchController.php b/app/Http/Controllers/MessageDispatchController.php
new file mode 100644
index 0000000..98159eb
--- /dev/null
+++ b/app/Http/Controllers/MessageDispatchController.php
@@ -0,0 +1,207 @@
+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);
+ }
+ }
+}
+
diff --git a/app/Http/Controllers/MessageSyncController.php b/app/Http/Controllers/MessageSyncController.php
new file mode 100644
index 0000000..22bee36
--- /dev/null
+++ b/app/Http/Controllers/MessageSyncController.php
@@ -0,0 +1,322 @@
+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);
+ }
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 452e6b6..0a38c0d 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -11,7 +11,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
- //
+ // 注册 JIRA 服务
+ $this->app->singleton(\App\Services\JiraService::class);
}
/**
diff --git a/app/Providers/ClientServiceProvider.php b/app/Providers/ClientServiceProvider.php
new file mode 100644
index 0000000..5a2ce1f
--- /dev/null
+++ b/app/Providers/ClientServiceProvider.php
@@ -0,0 +1,33 @@
+app->singleton(AgentClient::class, function () {
+ return new AgentClient();
+ });
+
+ $this->app->singleton(MonoClient::class, function () {
+ return new MonoClient();
+ });
+ }
+
+ /**
+ * Bootstrap services.
+ */
+ public function boot(): void
+ {
+ //
+ }
+}
+
diff --git a/app/Services/EnvService.php b/app/Services/EnvService.php
index ccd18c3..321f92f 100644
--- a/app/Services/EnvService.php
+++ b/app/Services/EnvService.php
@@ -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;
+ }
}
diff --git a/app/Services/EventConsumerSyncService.php b/app/Services/EventConsumerSyncService.php
new file mode 100644
index 0000000..411f01e
--- /dev/null
+++ b/app/Services/EventConsumerSyncService.php
@@ -0,0 +1,271 @@
+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);
+ }
+}
+
diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php
new file mode 100644
index 0000000..6e2fcbb
--- /dev/null
+++ b/app/Services/JiraService.php
@@ -0,0 +1,742 @@
+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(),
+ ]);
+ }
+ }
+ }
+
+
+}
diff --git a/app/Services/MessageDispatchService.php b/app/Services/MessageDispatchService.php
new file mode 100644
index 0000000..9e162ac
--- /dev/null
+++ b/app/Services/MessageDispatchService.php
@@ -0,0 +1,349 @@
+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(),
+ ];
+ }
+}
+
diff --git a/app/Services/MessageSyncService.php b/app/Services/MessageSyncService.php
new file mode 100644
index 0000000..62994a8
--- /dev/null
+++ b/app/Services/MessageSyncService.php
@@ -0,0 +1,191 @@
+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;
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index c183276..fdfaa9a 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -7,11 +7,18 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
+ api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
- //
+ // 为 API 路由添加 CSRF 豁免
+ $middleware->validateCsrfTokens(except: [
+ 'api/jira/*',
+ 'api/env/*',
+ 'api/message-sync/*',
+ 'api/message-dispatch/*'
+ ]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index 7f5cf75..bddfa3b 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -2,5 +2,6 @@
return [
App\Providers\AppServiceProvider::class,
+ App\Providers\ClientServiceProvider::class,
App\Providers\EnvServiceProvider::class,
];
diff --git a/composer.json b/composer.json
index dfe9c82..31b8114 100644
--- a/composer.json
+++ b/composer.json
@@ -8,7 +8,9 @@
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
- "laravel/tinker": "^2.10.1"
+ "laravel/tinker": "^2.10.1",
+ "lesstif/php-jira-rest-client": "5.10.0",
+ "ext-pdo": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",
diff --git a/composer.lock b/composer.lock
index 3002027..d158fc6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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": "88970a0117c062eed55fa8728fc43833",
+ "content-hash": "5955fb8cae2c82cdb7352006103c8c0c",
"packages": [
{
"name": "brick/math",
@@ -2006,6 +2006,73 @@
],
"time": "2024-12-08T08:18:47+00:00"
},
+ {
+ "name": "lesstif/php-jira-rest-client",
+ "version": "5.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/lesstif/php-jira-rest-client.git",
+ "reference": "46b20408c34138615acbdd58a86b8b624d864d0c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/lesstif/php-jira-rest-client/zipball/46b20408c34138615acbdd58a86b8b624d864d0c",
+ "reference": "46b20408c34138615acbdd58a86b8b624d864d0c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-curl": "*",
+ "ext-json": "*",
+ "monolog/monolog": "^2.0|^3.0",
+ "netresearch/jsonmapper": "^4.2",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0|^2.0",
+ "phpstan/phpstan": "^1.0|^2.0",
+ "phpunit/phpunit": "^9.0|^10.0",
+ "symfony/var-dumper": "^5.0|^6.0|^7.0"
+ },
+ "suggest": {
+ "vlucas/phpdotenv": "^5.0|^6.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "JiraRestApi\\JiraRestApiServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "JiraRestApi\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "KwangSeob Jeong",
+ "email": "lesstif@gmail.com",
+ "homepage": "https://lesstif.com/"
+ }
+ ],
+ "description": "JIRA REST API Client for PHP Users.",
+ "keywords": [
+ "jira",
+ "jira-php",
+ "jira-rest",
+ "rest"
+ ],
+ "support": {
+ "issues": "https://github.com/lesstif/php-jira-rest-client/issues",
+ "source": "https://github.com/lesstif/php-jira-rest-client/tree/5.10.0"
+ },
+ "time": "2025-04-05T12:50:11+00:00"
+ },
{
"name": "monolog/monolog",
"version": "3.9.0",
@@ -2214,6 +2281,57 @@
],
"time": "2025-06-21T15:19:35+00:00"
},
+ {
+ "name": "netresearch/jsonmapper",
+ "version": "v4.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cweiske/jsonmapper.git",
+ "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8e76efb98ee8b6afc54687045e1b8dba55ac76e5",
+ "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-spl": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0",
+ "squizlabs/php_codesniffer": "~3.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "JsonMapper": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "OSL-3.0"
+ ],
+ "authors": [
+ {
+ "name": "Christian Weiske",
+ "email": "cweiske@cweiske.de",
+ "homepage": "http://github.com/cweiske/jsonmapper/",
+ "role": "Developer"
+ }
+ ],
+ "description": "Map nested JSON structures onto PHP classes",
+ "support": {
+ "email": "cweiske@cweiske.de",
+ "issues": "https://github.com/cweiske/jsonmapper/issues",
+ "source": "https://github.com/cweiske/jsonmapper/tree/v4.5.0"
+ },
+ "time": "2024-09-08T10:13:13+00:00"
+ },
{
"name": "nette/schema",
"version": "v1.3.2",
diff --git a/config/database.php b/config/database.php
index 5b318f5..53e8df5 100644
--- a/config/database.php
+++ b/config/database.php
@@ -112,6 +112,66 @@ return [
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
+ 'crmslave' => [
+ 'driver' => 'mysql',
+ 'url' => env('CRMSLAVE_DB_URL'),
+ 'host' => env('CRMSLAVE_DB_HOST', '127.0.0.1'),
+ 'port' => env('CRMSLAVE_DB_PORT', '3306'),
+ 'database' => env('CRMSLAVE_DB_DATABASE', 'crmslave'),
+ 'username' => env('CRMSLAVE_DB_USERNAME', 'root'),
+ 'password' => env('CRMSLAVE_DB_PASSWORD', ''),
+ 'unix_socket' => env('CRMSLAVE_DB_SOCKET', ''),
+ 'charset' => env('CRMSLAVE_DB_CHARSET', 'utf8mb4'),
+ 'collation' => env('CRMSLAVE_DB_COLLATION', 'utf8mb4_unicode_ci'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('CRMSLAVE_MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+
+ 'agentslave' => [
+ 'driver' => 'mysql',
+ 'url' => env('AGENTSLAVE_DB_URL'),
+ 'host' => env('AGENTSLAVE_DB_HOST', '127.0.0.1'),
+ 'port' => env('AGENTSLAVE_DB_PORT', '3306'),
+ 'database' => env('AGENTSLAVE_DB_DATABASE', 'crm'),
+ 'username' => env('AGENTSLAVE_DB_USERNAME', 'root'),
+ 'password' => env('AGENTSLAVE_DB_PASSWORD', ''),
+ 'unix_socket' => env('AGENTSLAVE_DB_SOCKET', ''),
+ 'charset' => env('AGENTSLAVE_DB_CHARSET', 'utf8mb4'),
+ 'collation' => env('AGENTSLAVE_DB_COLLATION', 'utf8mb4_unicode_ci'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('AGENTSLAVE_MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+
+ 'monoslave' => [
+ 'driver' => 'mysql',
+ 'url' => env('MONOSLAVE_DB_URL'),
+ 'host' => env('MONOSLAVE_DB_HOST', '127.0.0.1'),
+ 'port' => env('MONOSLAVE_DB_PORT', '3306'),
+ 'database' => env('MONOSLAVE_DB_DATABASE', 'mono'),
+ 'username' => env('MONOSLAVE_DB_USERNAME', 'root'),
+ 'password' => env('MONOSLAVE_DB_PASSWORD', ''),
+ 'unix_socket' => env('MONOSLAVE_DB_SOCKET', ''),
+ 'charset' => env('MONOSLAVE_DB_CHARSET', 'utf8mb4'),
+ 'collation' => env('MONOSLAVE_DB_COLLATION', 'utf8mb4_unicode_ci'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('MONOSLAVE_MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+
],
/*
diff --git a/config/jira.php b/config/jira.php
new file mode 100644
index 0000000..c8f1ae3
--- /dev/null
+++ b/config/jira.php
@@ -0,0 +1,32 @@
+ env('JIRA_HOST', 'http://jira.eainc.com:8080'),
+ 'username' => env('JIRA_USERNAME'),
+ 'password' => env('JIRA_PASSWORD'),
+ 'api_token' => env('JIRA_API_TOKEN'),
+
+ // 认证方式: 'basic' 或 'token'
+ 'auth_type' => env('JIRA_AUTH_TYPE', 'basic'),
+
+ // 连接超时时间(秒)
+ 'timeout' => (int) env('JIRA_TIMEOUT', 30),
+
+ // 项目代码映射
+ 'project_codes' => [
+ 'WP' => 'WP',
+ 'AM' => 'AM',
+ ],
+
+ // 默认用户(如果未指定)
+ 'default_user' => env('JIRA_DEFAULT_USER'),
+];
diff --git a/config/services.php b/config/services.php
index 6182e4b..a7c90ff 100644
--- a/config/services.php
+++ b/config/services.php
@@ -35,4 +35,14 @@ return [
],
],
+ 'agent' => [
+ 'url' => env('AGENT_URL'),
+ 'timeout' => env('AGENT_TIMEOUT', 30),
+ ],
+
+ 'mono' => [
+ 'url' => env('MONO_URL'),
+ 'timeout' => env('MONO_TIMEOUT', 30),
+ ],
+
];
diff --git a/package-lock.json b/package-lock.json
index ab2fbd0..f35c554 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,17 @@
"packages": {
"": {
"dependencies": {
+ "@codemirror/autocomplete": "^6.18.3",
+ "@codemirror/commands": "^6.8.1",
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@codemirror/lang-php": "^6.0.2",
+ "@codemirror/language": "^6.10.6",
+ "@codemirror/search": "^6.5.11",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/theme-one-dark": "^6.1.3",
+ "@codemirror/view": "^6.38.1",
"@vitejs/plugin-vue": "^6.0.1",
+ "codemirror": "^6.0.2",
"vue": "^3.5.18"
},
"devDependencies": {
@@ -77,6 +87,157 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.18.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
+ "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
+ "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
+ }
+ },
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.9",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
+ "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
+ "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": {
+ "version": "6.8.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+ "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/lang-php": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
+ "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/php": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.11.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
+ "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.1.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
+ "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
+ "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.38.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
+ "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.5.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
@@ -544,6 +705,80 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@lezer/common": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
+ "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/css": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
+ "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
+ "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/html": {
+ "version": "1.3.10",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
+ "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
+ "integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
+ "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/php": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz",
+ "integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.1.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
@@ -1323,6 +1558,32 @@
"node": ">=12"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/codemirror/node_modules/@codemirror/lint": {
+ "version": "6.8.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
+ "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.35.0",
+ "crelt": "^1.0.5"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1382,6 +1643,12 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -2296,6 +2563,12 @@
"node": ">=8"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
+ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -2499,6 +2772,12 @@
}
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index 18aa0b6..ebce9c0 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,17 @@
"vite": "^7.0.4"
},
"dependencies": {
+ "@codemirror/autocomplete": "^6.18.3",
+ "@codemirror/commands": "^6.8.1",
+ "@codemirror/lang-javascript": "^6.2.4",
+ "@codemirror/lang-php": "^6.0.2",
+ "@codemirror/language": "^6.10.6",
+ "@codemirror/search": "^6.5.11",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/theme-one-dark": "^6.1.3",
+ "@codemirror/view": "^6.38.1",
"@vitejs/plugin-vue": "^6.0.1",
+ "codemirror": "^6.0.2",
"vue": "^3.5.18"
}
}
diff --git a/resources/js/app.js b/resources/js/app.js
index 17dc621..20e9782 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,11 +1,11 @@
import './bootstrap';
import { createApp } from 'vue';
-import EnvManager from './components/EnvManager.vue';
+import AdminDashboard from './components/admin/AdminDashboard.vue';
console.log('App.js loading...');
const app = createApp({});
-app.component('env-manager', EnvManager);
+app.component('admin-dashboard', AdminDashboard);
console.log('Mounting app...');
app.mount('#app');
diff --git a/resources/js/components/AdminLayout.vue b/resources/js/components/AdminLayout.vue
deleted file mode 100644
index d7c3cff..0000000
--- a/resources/js/components/AdminLayout.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
查询指定时间范围内的工时记录
+{{ workLogs.data.total_hours }} 小时
+{{ workLogs.data.total_records }} 条
+{{ workLogs.data.date_range.start }} 至 {{ workLogs.data.date_range.end }}
+| + 项目 + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + | ++ 任务 + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + | ++ 父任务 + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + | ++ 日期时间 + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + | ++ 工时 + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + | ++ 备注 + + {{ sortDirection === 'asc' ? '↑' : '↓' }} + + | +操作 | +
|---|---|---|---|---|---|---|
| {{ log.project_key }} | ++ + {{ log.issue_key }} {{ log.issue_summary }} + + | ++ + {{ log.parent_task.key }} {{ log.parent_task.summary }} + + - + | +{{ log.date }} {{ log.time }} |
+ {{ log.hours }}h | +{{ log.comment }} | ++ + | +
指定日期范围内没有找到工时记录
+{{ workLogs.error }}
+{{ selectedWorklog.id }}
+{{ selectedWorklog.project }} ({{ selectedWorklog.project_key }})
++ + {{ selectedWorklog.parent_task.key }} {{ selectedWorklog.parent_task.summary }} + + 无父任务 +
+{{ selectedWorklog.hours }}h ({{ selectedWorklog.time_spent }})
+{{ selectedWorklog.started }}
+{{ selectedWorklog.author.display_name }} ({{ selectedWorklog.author.name }})
+{{ selectedWorklog.created }}
+{{ selectedWorklog.updated }}
++ {{ selectedWorklog.comment || '无备注' }} +
+生成上周的工作周报
+{{ weeklyReport.result }}
+ {{ weeklyReport.error }}
+对比CRM和Agent的事件消费者消息,找出缺失的消息
+{{ error }}
+| Topic名称 | +缺失数量 | +占比 | +
|---|---|---|
| {{ item.topic }} | ++ + {{ item.count }} + + | ++ {{ ((item.count / compareResult.missing_count) * 100).toFixed(2) }}% + | +
| 消息ID | +消息名称 | +消息体 | +创建时间 | +
|---|---|---|---|
| {{ msg.msg_id }} | +{{ msg.event_name }} | ++ + | +{{ formatDateTime(msg.created) }} | +
所有消息都已同步到Agent!
+{{ selectedMessage?.msg_id }}
+{{ selectedMessage?.event_name }}
+{{ selectedMessage?.msg_body }}
+ {{ formatDateTime(selectedMessage?.created) }}
+{{ formatDateTime(selectedMessage?.updated) }}
+查询和管理异常的消息分发数据
+{{ error }}
+| + + | +消息ID | +国家代码 | +域名 | +事件名称 | +实体代码 | +请求状态 | +回调状态 | +Agent状态 | +重试次数 | +请求错误 | +回调错误 | +创建时间 | +更新时间 | +操作 | +
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + + | +{{ item.msg_id }} | +{{ item.country_code || '-' }} | +{{ item.domain || '-' }} | +{{ item.event_name }} | +{{ item.entity_code }} | ++ + {{ getStatusText(item.request_status) }} + + | ++ + {{ getBusinessStatusText(item.business_status) }} + + | ++ + {{ getConsumerStatusText(item.consumer_status) }} + + - + | +{{ item.retry_count }} | ++ {{ item.request_error_message || '-' }} + | ++ {{ item.business_error_message || '-' }} + | +{{ item.created || '-' }} | +{{ item.updated || '-' }} | ++ + | +
{{ detailItem.msg_id }}
+{{ detailItem.service_name }}
+{{ detailItem.request_error_message || '-' }}
+{{ detailItem.business_error_message || '-' }}
+{{ formatJson(detailItem.msg_body) }}
+ 批量输入消息ID,从crmslave数据库查询并同步到agent服务
+{{ error }}
+| 消息ID | +事件类型 | +跟踪ID | +时间戳 | +操作 | +
|---|---|---|---|---|
| {{ message.msg_id }} | +{{ message.event_type }} | +{{ message.trace_id }} | +{{ formatTimestamp(message.timestamp) }} | ++ + | +
| 消息ID | +状态 | +响应 | +操作 | +
|---|---|---|---|
| {{ result.msg_id }} | ++ + 成功 + + + 失败 + + | ++ {{ result.success ? '同步成功' : result.error }} + | ++ + | +
{{ JSON.stringify(selectedDetail, null, 2) }}
+