From 103340536b42fa67136746c2d8597cefb12ea481 Mon Sep 17 00:00:00 2001 From: tradewind Date: Wed, 12 Aug 2026 18:00:09 +0800 Subject: [PATCH] #feature: some update --- .env.example | 2 +- AGENTS.md | 88 ++ app/Clients/JenkinsClient.php | 254 +++++- .../Commands/ErpRequestReportCommand.php | 40 + app/Enums/CaseLabelBit.php | 90 +++ .../Controllers/Admin/ConfigController.php | 18 +- .../ErpRequestReportConfigController.php | 53 ++ .../Admin/JenkinsBuildController.php | 78 ++ .../ProductionDiagnosisController.php | 13 +- .../ProductionDiagnosisPageController.php | 18 + app/Http/Middleware/HostAccessMiddleware.php | 35 + app/Providers/AppServiceProvider.php | 2 + app/Services/DingTalkService.php | 45 +- app/Services/ErpRequestReportService.php | 220 +++++ app/Services/JiraService.php | 38 +- app/Services/ProductionDiagnosisService.php | 357 ++++++++- app/Services/ScheduledTaskService.php | 9 +- bootstrap/app.php | 2 + config/logging.php | 8 + config/toolbox.php | 3 +- phpunit.xml | 1 + .../js/components/admin/JenkinsBuilds.vue | 756 ++++++++++++++---- .../js/components/admin/SystemSettings.vue | 125 ++- resources/js/components/jira/JiraWorklog.vue | 56 +- .../js/components/jira/TestMailGenerator.vue | 23 +- .../components/tools/ProductionDiagnosis.vue | 94 ++- resources/js/production-diagnosis.js | 8 + .../production-diagnosis/index.blade.php | 15 + routes/api.php | 7 +- routes/console.php | 25 +- routes/web.php | 3 +- tests/Feature/HostAccessTest.php | 65 ++ tests/Feature/ProductionDiagnosisTest.php | 98 +++ tests/Unit/CaseLabelBitTest.php | 33 + tests/Unit/DingTalkServiceTest.php | 43 + tests/Unit/ErpRequestReportServiceTest.php | 267 +++++++ tests/Unit/JenkinsClientTest.php | 114 +++ tests/Unit/JiraServiceTest.php | 31 + vite.config.js | 6 +- 39 files changed, 2916 insertions(+), 227 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/Console/Commands/ErpRequestReportCommand.php create mode 100644 app/Enums/CaseLabelBit.php create mode 100644 app/Http/Controllers/Admin/ErpRequestReportConfigController.php create mode 100644 app/Http/Controllers/ProductionDiagnosisPageController.php create mode 100644 app/Http/Middleware/HostAccessMiddleware.php create mode 100644 app/Services/ErpRequestReportService.php create mode 100644 resources/js/production-diagnosis.js create mode 100644 resources/views/production-diagnosis/index.blade.php create mode 100644 tests/Feature/HostAccessTest.php create mode 100644 tests/Feature/ProductionDiagnosisTest.php create mode 100644 tests/Unit/CaseLabelBitTest.php create mode 100644 tests/Unit/DingTalkServiceTest.php create mode 100644 tests/Unit/ErpRequestReportServiceTest.php create mode 100644 tests/Unit/JenkinsClientTest.php diff --git a/.env.example b/.env.example index 629f149..2c12f34 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,7 @@ CRM_SERVICE_TIMEOUT=15 GIT_MONITOR_PROJECTS="service,portal-be,agent-be" # Admin IP whitelist (comma separated, supports wildcard: 192.168.* or 192.168.1.*) +TOOLBOX_ADMIN_HOST=toolbox.local TOOLBOX_ADMIN_IPS= # Alibaba Cloud SLS Configuration @@ -145,4 +146,3 @@ JENKINS_HOST=http://jenkins.example.com JENKINS_USERNAME= JENKINS_API_TOKEN= JENKINS_TIMEOUT=30 - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6500fcb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## 项目概述 + +Tradewind Toolbox 是一个基于 Laravel 12 的内部工具管理平台,提供 Vue 3 单页应用前端和 RESTful API 后端。主要功能模块包括: + +- **环境管理** - .env 文件的保存、应用、备份、恢复 +- **JIRA 集成** - 周报生成、工时日志查询 +- **消息同步** - 跨系统消息队列同步和对比 +- **消息分发** - 消息路由配置管理 +- **日志分析** - 阿里云 SLS 日志查询 + AI 分析 +- **Git 监控** - Release 分支检查、冲突检测 +- **Jenkins 监控** - 构建状态监控和钉钉通知 + +## 常用命令 + +```bash +# 开发环境(同时启动后端、队列、日志、前端) +composer dev + +# 运行测试 +composer test + +# PHP 代码格式化 +./vendor/bin/pint + +# 数据库迁移 +php artisan migrate + +# 清除缓存 +php artisan optimize:clear + +# 前端构建 +npm run build +``` + +## 核心架构 + +### 服务层 (`app/Services/`) + +业务逻辑集中在 Services 目录,所有服务在 `AppServiceProvider` 中注册为单例: + +| 服务 | 职责 | +|------|------| +| `ConfigService` | 数据库键值配置存储 | +| `JiraService` | JIRA REST API 集成 | +| `SlsService` | 阿里云 SLS 日志查询 | +| `AiService` | AI 提供商管理(支持 OpenAI 兼容接口) | +| `LogAnalysisService` | 日志分析编排(SLS → AI → 代码分析) | +| `CodeAnalysisService` | 代码级分析(调用 Gemini/Codex CLI) | +| `GitMonitorService` | Git 仓库监控 | +| `JenkinsMonitorService` | Jenkins 构建监控 | +| `DingTalkService` | 钉钉 Webhook 通知 | +| `EnvService` | .env 文件管理 | +| `ScheduledTaskService` | 定时任务动态控制 | + +### 外部客户端 (`app/Clients/`) + +封装外部服务调用:`AiClient`、`SlsClient`、`JenkinsClient`、`AgentClient`、`MonoClient` + +### 定时任务 (`routes/console.php`) + +所有定时任务可在管理后台动态启用/禁用,状态存储在 `configs` 表: + +- `git-monitor:check` - 每 10 分钟检查 release 分支 +- `git-monitor:cache` - 每天 02:00 刷新 release 缓存 +- `log-analysis:run` - 每天 02:00 执行日志+代码分析 +- `jenkins:monitor` - 每分钟检查 Jenkins 构建 + +### 队列任务 (`app/Jobs/`) + +`LogAnalysisJob` - 后台执行日志分析:获取日志 → 按 app 分组 → AI 分析 → 代码分析 → 保存报告 → 推送通知 + +### 路由结构 + +- **Web 路由** (`routes/web.php`) - 所有页面通过 `AdminController@index` 渲染 Vue SPA +- **API 路由** (`routes/api.php`) - RESTful API,按模块分组(env、jira、log-analysis、admin 等) +- **中间件** - `AdminIpMiddleware` IP 白名单、`OperationLogMiddleware` 操作审计 + +## 技术栈 + +- **后端**: PHP 8.2+, Laravel 12, PHPUnit 11 +- **前端**: Vue 3, Vite 7, Tailwind CSS 4, CodeMirror 6 +- **数据库**: SQLite (默认) / MySQL +- **队列**: Database 驱动 +- **外部集成**: JIRA、阿里云 SLS、OpenAI 兼容 API、钉钉、Jenkins diff --git a/app/Clients/JenkinsClient.php b/app/Clients/JenkinsClient.php index f622ed6..93b49fe 100644 --- a/app/Clients/JenkinsClient.php +++ b/app/Clients/JenkinsClient.php @@ -92,10 +92,12 @@ class JenkinsClient ]; } - $path = $this->getJobPath($jobName).(empty($parameters) ? '/build' : '/buildWithParameters'); + $path = $this->getJobPath($jobName).'/build?delay=0sec'; $url = $this->host.$path; try { + $jobInfo = $this->getJobInfo($jobName); + $nextBuildNumber = isset($jobInfo['nextBuildNumber']) ? (int) $jobInfo['nextBuildNumber'] : null; $request = $this->http(); $crumb = $this->getCrumb(); if ($crumb) { @@ -104,12 +106,20 @@ class JenkinsClient $response = empty($parameters) ? $request->post($url) - : $request->asForm()->post($url, $parameters); + : $request->asForm()->post($url, [ + 'json' => json_encode([ + 'parameter' => $this->buildFormParameters($parameters), + 'statusCode' => '303', + 'redirectTo' => '.', + ], JSON_UNESCAPED_UNICODE), + 'Submit' => 'Build', + ]); if ($response->successful() || $response->status() === 201) { return [ 'success' => true, 'queue_url' => $response->header('Location'), + 'build_number' => $nextBuildNumber, 'status' => $response->status(), ]; } @@ -158,6 +168,143 @@ class JenkinsClient return $result; } + public function getBuildStatus(string $jobName, ?string $queueUrl = null, ?int $buildNumber = null): array + { + if (! $this->isConfigured()) { + Log::warning('Jenkins client is not configured'); + + return [ + 'success' => false, + 'status' => 'UNKNOWN', + 'message' => 'Jenkins not configured', + ]; + } + + $queueItem = null; + if ($queueUrl && ! $buildNumber) { + $queueItem = $this->getQueueItem($queueUrl); + if (! $queueItem) { + return [ + 'success' => false, + 'status' => 'UNKNOWN', + 'queue_url' => $queueUrl, + 'message' => '无法获取 Jenkins 队列状态', + ]; + } + + if ($queueItem['cancelled'] ?? false) { + return [ + 'success' => true, + 'status' => 'ABORTED', + 'result' => 'ABORTED', + 'completed' => true, + 'queue_url' => $queueUrl, + ]; + } + + if (empty($queueItem['executable']['number'])) { + return [ + 'success' => true, + 'status' => 'PENDING', + 'building' => true, + 'completed' => false, + 'queue_url' => $queueUrl, + 'message' => $queueItem['why'] ?? null, + ]; + } + + $buildNumber = (int) $queueItem['executable']['number']; + } + + if (! $buildNumber) { + return [ + 'success' => false, + 'status' => 'UNKNOWN', + 'queue_url' => $queueUrl, + 'message' => '缺少 Jenkins 构建号', + ]; + } + + $buildInfo = $this->getBuildInfo($jobName, $buildNumber); + if (! $buildInfo) { + return [ + 'success' => true, + 'status' => 'PENDING', + 'building' => true, + 'completed' => false, + 'build_number' => $buildNumber, + 'queue_url' => $queueUrl, + 'message' => $this->findQueuedItem($jobName) ? '等待 Jenkins 开始构建' : '等待 Jenkins 创建构建', + ]; + } + + $building = (bool) ($buildInfo['building'] ?? false); + $result = $buildInfo['result'] ?? null; + + return [ + 'success' => true, + 'status' => $building ? 'BUILDING' : ($result ?? 'UNKNOWN'), + 'result' => $result, + 'building' => $building, + 'completed' => ! $building && ! empty($result), + 'build_number' => $buildNumber, + 'build_url' => $buildInfo['url'] ?? ($queueItem['executable']['url'] ?? null), + 'queue_url' => $queueUrl, + ]; + } + + public function cancelBuild(string $jobName, ?string $queueUrl = null, ?int $buildNumber = null): array + { + if (! $this->isConfigured()) { + Log::warning('Jenkins client is not configured'); + + return [ + 'success' => false, + 'message' => 'Jenkins not configured', + ]; + } + + if ($queueUrl && ! $buildNumber) { + $queueItem = $this->getQueueItem($queueUrl); + if (! empty($queueItem['executable']['number'])) { + $buildNumber = (int) $queueItem['executable']['number']; + } else { + $queueId = $this->extractQueueId($queueUrl); + if (! $queueId) { + return [ + 'success' => false, + 'message' => '无法识别 Jenkins 队列 ID', + ]; + } + + return [ + ...$this->post('/queue/cancelItem?id='.rawurlencode($queueId), 'Jenkins queue cancel'), + 'cancelled_queue' => true, + ]; + } + } + + $queueItem = $this->findQueuedItem($jobName); + if (! empty($queueItem['id'])) { + return [ + ...$this->post('/queue/cancelItem?id='.rawurlencode((string) $queueItem['id']), 'Jenkins queue cancel'), + 'cancelled_queue' => true, + ]; + } + + if (! $buildNumber) { + return [ + 'success' => false, + 'message' => '缺少 Jenkins 构建号', + ]; + } + + return [ + ...$this->post($this->getJobPath($jobName)."/{$buildNumber}/stop", 'Jenkins build stop'), + 'stopping_build' => true, + ]; + } + private function request(string $path): ?array { if (! $this->isConfigured()) { @@ -191,6 +338,60 @@ class JenkinsClient } } + private function post(string $path, string $operation): array + { + $url = $this->host.$path; + + try { + $request = $this->http(); + $crumb = $this->getCrumb(); + if ($crumb) { + $request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]); + } + + $response = $request->post($url); + if ($response->successful() || in_array($response->status(), [201, 302], true)) { + return [ + 'success' => true, + 'status' => $response->status(), + ]; + } + + Log::warning($operation.' failed', [ + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'success' => false, + 'message' => 'Jenkins 返回状态码 '.$response->status(), + 'status' => $response->status(), + ]; + } catch (\Throwable $e) { + Log::error($operation.' error', [ + 'url' => $url, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + private function buildFormParameters(array $parameters): array + { + return collect($parameters) + ->map(fn ($value, $name) => [ + 'name' => $name, + 'value' => $value, + ]) + ->values() + ->all(); + } + private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string { if (! $this->isConfigured()) { @@ -278,6 +479,55 @@ class JenkinsClient return $parameters; } + private function getQueueItem(string $queueUrl): ?array + { + $path = $this->normalizeJenkinsPath($queueUrl); + $path = rtrim($path, '/').'/api/json'; + + return $this->request($path); + } + + private function findQueuedItem(string $jobName): ?array + { + $queue = $this->request('/queue/api/json'); + if (empty($queue['items']) || ! is_array($queue['items'])) { + return null; + } + + $normalizedJobName = trim($jobName, '/'); + $lastSegment = basename(str_replace('\\', '/', $normalizedJobName)); + + foreach ($queue['items'] as $item) { + $task = $item['task'] ?? []; + $taskName = $task['fullName'] ?? $task['name'] ?? ''; + + if ($taskName === $normalizedJobName || $taskName === $lastSegment) { + return $item; + } + } + + return null; + } + + private function normalizeJenkinsPath(string $pathOrUrl): string + { + $path = parse_url($pathOrUrl, PHP_URL_PATH) ?: $pathOrUrl; + $query = parse_url($pathOrUrl, PHP_URL_QUERY); + + return $query ? "{$path}?{$query}" : $path; + } + + private function extractQueueId(string $queueUrl): ?string + { + if (preg_match('#/queue/item/(\d+)#', $queueUrl, $matches)) { + return $matches[1]; + } + + parse_str(parse_url($queueUrl, PHP_URL_QUERY) ?: '', $query); + + return isset($query['id']) ? (string) $query['id'] : null; + } + private function http(): PendingRequest { return Http::timeout($this->timeout) diff --git a/app/Console/Commands/ErpRequestReportCommand.php b/app/Console/Commands/ErpRequestReportCommand.php new file mode 100644 index 0000000..97743d1 --- /dev/null +++ b/app/Console/Commands/ErpRequestReportCommand.php @@ -0,0 +1,40 @@ +sendReport( + $this->option('date'), + $this->option('from'), + $this->option('to') + ); + + Log::channel('erp-request-report')->info('ERP 请求日报已发送', $result); + $this->info("ERP 请求日报已发送:{$result['date']},{$result['company_count']} 家公司,{$result['request_count']} 次请求。"); + + return self::SUCCESS; + } catch (\Throwable $e) { + Log::channel('erp-request-report')->error('ERP 请求日报发送失败', [ + 'message' => $e->getMessage(), + ]); + $this->error($e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Enums/CaseLabelBit.php b/app/Enums/CaseLabelBit.php new file mode 100644 index 0000000..ba395ec --- /dev/null +++ b/app/Enums/CaseLabelBit.php @@ -0,0 +1,90 @@ + CRM 侧原始位含义 */ + private const CRM_LABELS = [ + self::ALLOW_PROCESS_BY_HONEST => '允许APP授信放行', + self::APPLIANCE_NEED_MONEY => '新病例订单卡生产', + self::UPGRADE_NEED_MONEY => '产品变更卡生产', + self::EXTENSION_NEED_MONEY => '病例延期卡生产', + ]; + + /** @var array 面向用户的原因解释 */ + private const DESCRIPTIONS = [ + self::ALLOW_PROCESS_BY_HONEST => '允许 APP 走授信放行的开关,不属于卡款原因,不会计入 is_need_pfp', + self::APPLIANCE_NEED_MONEY => '新病例订单款项未结清,CRM 把病例卡在生产前,需要代理在代理端确认进产', + self::UPGRADE_NEED_MONEY => '病例做了产品变更(升档),差价款项未结清,需要代理确认进产后才会放行', + self::EXTENSION_NEED_MONEY => '病例服务年限延期的费用未结清,需要先结清费用才能继续生产', + ]; + + public static function crmLabel(int $bit): string + { + return self::CRM_LABELS[$bit] ?? ('未知标记位 '.$bit); + } + + public static function description(int $bit): string + { + return self::DESCRIPTIONS[$bit] ?? '未在 CRM 枚举中定义的标记位,需确认 CRM 是否新增了卡款原因'; + } + + /** + * 拆出位图中所有置位的 bit + * + * @return int[] + */ + public static function split(int $value): array + { + $bits = []; + for ($bit = 1; $bit > 0 && $bit <= $value; $bit <<= 1) { + if (($value & $bit) === $bit) { + $bits[] = $bit; + } + } + + return $bits; + } + + /** + * 把位图翻译成可读文本,如「新病例订单卡生产 / 产品变更卡生产」 + */ + public static function toText(int $value): string + { + if ($value <= 0) { + return '无标记'; + } + + return implode(' / ', array_map( + static fn (int $bit): string => self::crmLabel($bit), + self::split($value) + )); + } +} diff --git a/app/Http/Controllers/Admin/ConfigController.php b/app/Http/Controllers/Admin/ConfigController.php index 53bfe03..49980af 100644 --- a/app/Http/Controllers/Admin/ConfigController.php +++ b/app/Http/Controllers/Admin/ConfigController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Config; +use App\Services\ErpRequestReportService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -14,6 +15,7 @@ class ConfigController extends Controller public function index(): JsonResponse { $configs = Config::query() + ->where('key', '!=', ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY) ->orderBy('key') ->get(); @@ -28,7 +30,7 @@ class ConfigController extends Controller public function store(Request $request): JsonResponse { $data = $request->validate([ - 'key' => ['required', 'string', 'max:255', 'unique:configs,key'], + 'key' => ['required', 'string', 'max:255', 'unique:configs,key', 'not_in:'.ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY], 'value' => ['nullable', 'string'], 'description' => ['nullable', 'string', 'max:255'], ]); @@ -49,12 +51,15 @@ class ConfigController extends Controller public function update(Request $request, Config $config): JsonResponse { + $this->ensureNotProtected($config); + $data = $request->validate([ 'key' => [ 'required', 'string', 'max:255', Rule::unique('configs', 'key')->ignore($config->id), + 'not_in:'.ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY, ], 'value' => ['nullable', 'string'], 'description' => ['nullable', 'string', 'max:255'], @@ -76,6 +81,8 @@ class ConfigController extends Controller public function destroy(Config $config): JsonResponse { + $this->ensureNotProtected($config); + $config->delete(); return response()->json([ @@ -103,4 +110,13 @@ class ConfigController extends Controller return $decoded; } + + private function ensureNotProtected(Config $config): void + { + if ($config->key === ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY) { + throw ValidationException::withMessages([ + 'key' => '该配置只能通过 ERP 请求日报设置修改', + ]); + } + } } diff --git a/app/Http/Controllers/Admin/ErpRequestReportConfigController.php b/app/Http/Controllers/Admin/ErpRequestReportConfigController.php new file mode 100644 index 0000000..7af8a5b --- /dev/null +++ b/app/Http/Controllers/Admin/ErpRequestReportConfigController.php @@ -0,0 +1,53 @@ +json([ + 'success' => true, + 'data' => [ + 'dingtalk_token_configured' => filled($this->configService->get(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)), + ], + ]); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'dingtalk_token' => ['required', 'string', 'max:2048'], + ]); + + $token = trim($validated['dingtalk_token']); + if ($token === '') { + throw ValidationException::withMessages([ + 'dingtalk_token' => 'Token 不能为空', + ]); + } + + $this->configService->set( + ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY, + $token, + 'ERP 请求日报钉钉机器人 Token' + ); + + return response()->json([ + 'success' => true, + 'message' => 'ERP 请求日报钉钉机器人 Token 已保存', + 'data' => [ + 'dingtalk_token_configured' => true, + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/JenkinsBuildController.php b/app/Http/Controllers/Admin/JenkinsBuildController.php index f0ebffb..2482506 100644 --- a/app/Http/Controllers/Admin/JenkinsBuildController.php +++ b/app/Http/Controllers/Admin/JenkinsBuildController.php @@ -82,6 +82,7 @@ class JenkinsBuildController extends Controller 'success' => (bool) ($result['success'] ?? false), 'message' => $result['message'] ?? null, 'queue_url' => $result['queue_url'] ?? null, + 'build_number' => $result['build_number'] ?? null, ]; } @@ -94,6 +95,83 @@ class JenkinsBuildController extends Controller ], $successCount > 0 ? 200 : 422); } + public function statuses(Request $request): JsonResponse + { + if (! $this->jenkinsClient->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息', + ], 422); + } + + $projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all(); + $data = $request->validate([ + 'builds' => ['required', 'array', 'min:1'], + 'builds.*.id' => ['required', 'string'], + 'builds.*.project_slug' => ['required', 'string', Rule::in($projectSlugs)], + 'builds.*.queue_url' => ['nullable', 'string'], + 'builds.*.build_number' => ['nullable', 'integer'], + ]); + + $projects = Project::getJenkinsNotifyEnabled()->keyBy('slug'); + $results = []; + + foreach ($data['builds'] as $build) { + /** @var Project $project */ + $project = $projects[$build['project_slug']]; + + $results[] = [ + 'id' => $build['id'], + 'project_slug' => $project->slug, + 'job_name' => $project->jenkins_job_name, + ...$this->jenkinsClient->getBuildStatus( + $project->jenkins_job_name, + $build['queue_url'] ?? null, + isset($build['build_number']) ? (int) $build['build_number'] : null + ), + ]; + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'results' => $results, + ], + ]); + } + + public function cancel(Request $request): JsonResponse + { + if (! $this->jenkinsClient->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息', + ], 422); + } + + $projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all(); + $data = $request->validate([ + 'project_slug' => ['required', 'string', Rule::in($projectSlugs)], + 'queue_url' => ['nullable', 'string'], + 'build_number' => ['nullable', 'integer'], + ]); + + $project = Project::getJenkinsNotifyEnabled()->firstWhere('slug', $data['project_slug']); + $result = $this->jenkinsClient->cancelBuild( + $project->jenkins_job_name, + $data['queue_url'] ?? null, + isset($data['build_number']) ? (int) $data['build_number'] : null + ); + + return response()->json([ + 'success' => (bool) ($result['success'] ?? false), + 'message' => ($result['success'] ?? false) ? '已发送取消请求' : ($result['message'] ?? '取消失败'), + 'data' => [ + 'result' => $result, + ], + ], ($result['success'] ?? false) ? 200 : 422); + } + private function normalizeParameters(array $parameters): array { return collect($parameters) diff --git a/app/Http/Controllers/ProductionDiagnosisController.php b/app/Http/Controllers/ProductionDiagnosisController.php index 90da8e7..c9d0106 100644 --- a/app/Http/Controllers/ProductionDiagnosisController.php +++ b/app/Http/Controllers/ProductionDiagnosisController.php @@ -5,13 +5,12 @@ namespace App\Http\Controllers; use App\Services\ProductionDiagnosisService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; class ProductionDiagnosisController extends Controller { - public function __construct(private readonly ProductionDiagnosisService $service) - { - } + public function __construct(private readonly ProductionDiagnosisService $service) {} /** * 单条进产诊断 @@ -37,9 +36,15 @@ class ProductionDiagnosisController extends Controller 'errors' => $e->errors(), ], 422); } catch (\Throwable $e) { + Log::error('Production diagnosis failed.', [ + 'type' => $request->input('type'), + 'code' => $request->input('code'), + 'exception' => $e, + ]); + return response()->json([ 'success' => false, - 'message' => '诊断失败: '.$e->getMessage(), + 'message' => '诊断服务暂不可用,请稍后重试', ], 500); } } diff --git a/app/Http/Controllers/ProductionDiagnosisPageController.php b/app/Http/Controllers/ProductionDiagnosisPageController.php new file mode 100644 index 0000000..ac95798 --- /dev/null +++ b/app/Http/Controllers/ProductionDiagnosisPageController.php @@ -0,0 +1,18 @@ +getHost()) === config('toolbox.admin_host')) { + return view('admin.index'); + } + + return view('production-diagnosis.index'); + } +} diff --git a/app/Http/Middleware/HostAccessMiddleware.php b/app/Http/Middleware/HostAccessMiddleware.php new file mode 100644 index 0000000..7b9a008 --- /dev/null +++ b/app/Http/Middleware/HostAccessMiddleware.php @@ -0,0 +1,35 @@ +getHost(), '[]')); + $adminHost = strtolower((string) config('toolbox.admin_host', 'toolbox.local')); + + if ($host === $adminHost) { + return $next($request); + } + + if (filter_var($host, FILTER_VALIDATE_IP) === false || ! $this->isPublicDiagnosisRequest($request)) { + abort(404); + } + + return $next($request); + } + + private function isPublicDiagnosisRequest(Request $request): bool + { + return ($request->isMethod('GET') && $request->is('production-diagnosis')) + || ($request->isMethod('POST') && $request->is('api/production-diagnosis/diagnose')); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1c86abe..7597bfb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use App\Services\CodeContextService; use App\Services\ConfigService; use App\Services\DingTalkService; use App\Services\EnvService; +use App\Services\ErpRequestReportService; use App\Services\GitMonitorService; use App\Services\JiraService; use App\Services\LogAnalysisService; @@ -38,6 +39,7 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(JiraService::class); $this->app->singleton(DingTalkService::class); $this->app->singleton(EnvService::class); + $this->app->singleton(ErpRequestReportService::class); $this->app->singleton(GitMonitorService::class); $this->app->singleton(SlsService::class); $this->app->singleton(AiService::class); diff --git a/app/Services/DingTalkService.php b/app/Services/DingTalkService.php index 14fbbcb..9f0dea1 100644 --- a/app/Services/DingTalkService.php +++ b/app/Services/DingTalkService.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log; class DingTalkService { private ?string $webhook; + private ?string $secret; public function __construct() @@ -25,9 +26,32 @@ class DingTalkService 'atMobiles' => $atMobiles, 'atAll' => $atAll, ]); + return; } + $this->sendTextToWebhook($this->webhook, $message, $atMobiles, $atAll, $this->secret); + } + + public function sendTextToToken(string $token, string $message, array $atMobiles = [], bool $atAll = false): bool + { + $token = trim($token); + if ($token === '') { + Log::warning('DingTalk robot token is not configured, skip sending alert.'); + + return false; + } + + return $this->sendTextToWebhook( + 'https://oapi.dingtalk.com/robot/send?access_token='.urlencode($token), + $message, + $atMobiles, + $atAll + ); + } + + private function sendTextToWebhook(string $webhook, string $message, array $atMobiles, bool $atAll, ?string $secret = null): bool + { $payload = [ 'msgtype' => 'text', 'text' => [ @@ -39,22 +63,33 @@ class DingTalkService ], ]; - $url = $this->webhook; - if (!empty($this->secret)) { + $url = $webhook; + if (! empty($secret)) { $timestamp = (int) round(microtime(true) * 1000); - $stringToSign = $timestamp . "\n" . $this->secret; - $sign = base64_encode(hash_hmac('sha256', $stringToSign, $this->secret, true)); + $stringToSign = $timestamp."\n".$secret; + $sign = base64_encode(hash_hmac('sha256', $stringToSign, $secret, true)); $encodedSign = urlencode($sign); $separator = str_contains($url, '?') ? '&' : '?'; $url .= "{$separator}timestamp={$timestamp}&sign={$encodedSign}"; } try { - Http::timeout(10)->asJson()->post($url, $payload); + $response = Http::timeout(10)->asJson()->post($url, $payload); + + if ($response->successful() && (int) $response->json('errcode', -1) === 0) { + return true; + } + + Log::error('DingTalk alert was rejected', [ + 'status' => $response->status(), + 'errcode' => $response->json('errcode'), + ]); } catch (\Throwable $e) { Log::error('Failed to send DingTalk alert', [ 'message' => $e->getMessage(), ]); } + + return false; } } diff --git a/app/Services/ErpRequestReportService.php b/app/Services/ErpRequestReportService.php new file mode 100644 index 0000000..66b22de --- /dev/null +++ b/app/Services/ErpRequestReportService.php @@ -0,0 +1,220 @@ +configService->get(self::DINGTALK_TOKEN_CONFIG_KEY)); + + if ($token === '') { + throw new RuntimeException('未配置 ERP 请求日报的钉钉机器人 Token'); + } + + [$start, $end] = $this->resolvePeriod($date, $from, $to); + $periodLabel = $this->formatPeriodLabel($start, $end); + + $records = $this->database->connection('agentslave') + ->table('request_records') + ->selectRaw("agents.name as agent_name, agents.code as agent_code, SUBSTRING_INDEX(request_records.request_uri, '?', 1) as request_uri, COUNT(*) as request_count") + ->leftJoin('agents', 'agents.id', '=', 'request_records.user_id') + ->where('request_records.created', '>=', $start->toDateTimeString()) + ->where('request_records.created', '<', $end->toDateTimeString()) + ->where('request_records.request_uri', 'like', '/openapi/erp/%') + ->groupByRaw("agents.id, agents.name, agents.code, SUBSTRING_INDEX(request_records.request_uri, '?', 1)") + ->orderBy('agents.name') + ->orderBy('agents.code') + ->orderByRaw("SUBSTRING_INDEX(request_records.request_uri, '?', 1)") + ->get(); + + $messages = $this->formatMessages($periodLabel, $records); + $messageCount = count($messages); + + foreach ($messages as $index => $message) { + if (! $this->dingTalkService->sendTextToToken($token, $message)) { + throw new RuntimeException(sprintf('ERP 请求日报第 %d/%d 条发送到钉钉失败', $index + 1, $messageCount)); + } + } + + return [ + 'date' => $periodLabel, + 'from' => $start->toDateTimeString(), + 'to' => $end->subSecond()->toDateTimeString(), + 'company_count' => $records->groupBy(fn ($record) => $record->agent_name."\0".$record->agent_code)->count(), + 'request_count' => $records->sum('request_count'), + ]; + } + + /** + * @return array{0: CarbonImmutable, 1: CarbonImmutable} half-open interval [start, end) + */ + private function resolvePeriod(?string $date, ?string $from, ?string $to): array + { + $date = $this->normalizeOption($date); + $from = $this->normalizeOption($from); + $to = $this->normalizeOption($to); + + if ($date !== null && ($from !== null || $to !== null)) { + throw new InvalidArgumentException('--date 不能与 --from/--to 同时使用'); + } + + if ($date !== null) { + if (! $this->isDateOnly($date)) { + throw new InvalidArgumentException('--date 仅支持 Y-m-d 格式'); + } + + $start = CarbonImmutable::parse($date, self::REPORT_TIMEZONE)->startOfDay(); + + return [$start, $start->addDay()]; + } + + if ($from === null && $to === null) { + $start = CarbonImmutable::now(self::REPORT_TIMEZONE)->subDay()->startOfDay(); + + return [$start, $start->addDay()]; + } + + if ($from === null || $to === null) { + throw new InvalidArgumentException('--from 与 --to 需要同时指定'); + } + + $start = $this->parseBound($from, isStart: true); + $end = $this->parseBound($to, isStart: false); + + if ($end->lessThanOrEqualTo($start)) { + throw new InvalidArgumentException('结束时间必须晚于开始时间'); + } + + return [$start, $end]; + } + + private function parseBound(string $value, bool $isStart): CarbonImmutable + { + $parsed = CarbonImmutable::parse($value, self::REPORT_TIMEZONE); + + if ($this->isDateOnly($value)) { + return $isStart ? $parsed->startOfDay() : $parsed->startOfDay()->addDay(); + } + + return $isStart ? $parsed : $parsed->addSecond(); + } + + private function isDateOnly(string $value): bool + { + return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value); + } + + private function normalizeOption(?string $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim($value); + + return $value === '' ? null : $value; + } + + private function formatPeriodLabel(CarbonImmutable $start, CarbonImmutable $end): string + { + $inclusiveEnd = $end->subSecond(); + $isFullDayStart = $start->format('H:i:s') === '00:00:00'; + $isFullDayEnd = $inclusiveEnd->format('H:i:s') === '23:59:59'; + $spansSingleDay = $start->toDateString() === $inclusiveEnd->toDateString(); + + if ($isFullDayStart && $isFullDayEnd && $spansSingleDay) { + return $start->toDateString(); + } + + if ($isFullDayStart && $isFullDayEnd) { + return $start->toDateString().' ~ '.$inclusiveEnd->toDateString(); + } + + return $start->format('Y-m-d H:i:s').' ~ '.$inclusiveEnd->format('Y-m-d H:i:s'); + } + + /** + * @return array + */ + private function formatMessages(string $periodLabel, Collection $records): array + { + $title = "{$periodLabel} ERP OpenAPI 请求统计"; + + if ($records->isEmpty()) { + return ["{$title}\n无请求记录"]; + } + + $messages = [$title]; + + foreach ($records->groupBy(fn ($record) => $record->agent_name."\0".$record->agent_code) as $companyRecords) { + $first = $companyRecords->first(); + $company = trim(($first->agent_name ?: '未知机构').' '.($first->agent_code ?: '')); + + $this->appendCompanyRecords($messages, $title, $company, $companyRecords); + } + + if (count($messages) === 1) { + return $messages; + } + + return array_map( + fn (string $message, int $index) => "{$message}(第 ".($index + 1).'/'.count($messages).' 条)', + $messages, + array_keys($messages) + ); + } + + private function appendCompanyRecords(array &$messages, string $title, string $company, Collection $records): void + { + foreach ($records->values() as $index => $record) { + $line = $this->formatRequestLine($record); + $messageIndex = array_key_last($messages); + $isFirstRecord = $index === 0; + $addition = $isFirstRecord + ? "\n\n{$company}\n{$line}" + : "\n{$line}"; + + if (strlen($messages[$messageIndex].$addition) <= self::MAX_MESSAGE_BYTES) { + $messages[$messageIndex] .= $addition; + + continue; + } + + $messages[] = "{$title}\n\n{$company}\n{$line}"; + } + } + + private function formatRequestLine(object $record): string + { + $uri = explode('?', (string) $record->request_uri, 2)[0]; + $uri = preg_replace('/[\\x00-\\x1F\\x7F]/u', '', $uri) ?? ''; + + return Str::limit($uri, 1_000, '…')." {$record->request_count}次"; + } +} diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index 6145d7b..46f6946 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -331,6 +331,16 @@ class JiraService ['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'], ], ], + 'mono' => [ + 'label' => 'mono', + 'system' => 'SP', + 'current_version' => $this->resolveTestMailCurrentVersion('portal-mono-be', '1.11.0.0'), + 'database_enabled' => false, + 'containers' => [ + ['name' => 'portal-mono-be-aplct', 'location' => '中国'], + ['name' => 'portal-mono-be-web', 'location' => '中国'], + ], + ], ]; foreach ($containerGroups as &$group) { @@ -368,10 +378,15 @@ class JiraService } private function nextTestMailVersion(string $version): string + { + return $this->nextMinorVersion($version) ?? $version; + } + + private function nextMinorVersion(string $version): ?string { $parts = explode('.', trim($version)); if (count($parts) < 2 || ! ctype_digit($parts[1])) { - return $version; + return null; } $parts[1] = (string) ((int) $parts[1] + 1); @@ -379,6 +394,20 @@ class JiraService return implode('.', $parts); } + private function buildFallbackReleaseVersion(string $currentVersion): ?array + { + $version = $this->nextMinorVersion($currentVersion); + if ($version === null) { + return null; + } + + return [ + 'version' => $version, + 'description' => null, + 'release_date' => null, + ]; + } + public function buildTestMailDatabases(array $selectedGroups, array $versions): array { $defaults = $this->getTestMailTemplateDefaults(); @@ -389,6 +418,9 @@ class JiraService continue; } $group = $defaults['container_groups'][$groupKey]; + if (($group['database_enabled'] ?? true) === false) { + continue; + } $version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? '')); $branch = $version !== '' ? 'release/'.$version : ''; $exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch); @@ -1498,7 +1530,7 @@ class JiraService ->first(); if (! $candidate) { - return null; + return $this->buildFallbackReleaseVersion($currentVersion); } return [ @@ -1517,7 +1549,7 @@ class JiraService ->first(); if (! $candidate) { - return null; + return $this->buildFallbackReleaseVersion($currentVersion); } return [ diff --git a/app/Services/ProductionDiagnosisService.php b/app/Services/ProductionDiagnosisService.php index 37a4136..d5a9a4f 100644 --- a/app/Services/ProductionDiagnosisService.php +++ b/app/Services/ProductionDiagnosisService.php @@ -3,7 +3,9 @@ namespace App\Services; use App\Clients\CrmClient; +use App\Enums\CaseLabelBit; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * 进产诊断服务 @@ -18,15 +20,31 @@ use Illuminate\Support\Facades\DB; class ProductionDiagnosisService { public const TYPE_CASE = 'case'; + public const TYPE_BUSINESS = 'business_document'; + public const TYPE_SALE = 'sale_document'; + /** @var string agent-be configs 表中的卡款原因配置键 */ + private const STUCK_PAYMENT_REASON_KEY = 'stuck_payment_reason'; + + /** + * 读不到 configs 表时的兜底,与当前生产配置保持一致 + * + * @var array + */ + private const DEFAULT_STUCK_PAYMENT_REASONS = [ + CaseLabelBit::APPLIANCE_NEED_MONEY => '新病例进产', + CaseLabelBit::UPGRADE_NEED_MONEY => '转产品', + ]; + /** @var string agent-be 数据库连接名 */ private string $connection = 'agentslave'; - public function __construct(private readonly CrmClient $crm) - { - } + /** @var string CRM 数据库连接名,用于回溯 label_bit 原始值 */ + private string $crmConnection = 'crmslave'; + + public function __construct(private readonly CrmClient $crm) {} /** * 执行单次诊断 @@ -36,7 +54,7 @@ class ProductionDiagnosisService $code = trim($code); $entity = $this->findEntity($type, $code); - if (!$entity) { + if (! $entity) { return [ 'type' => $type, 'type_label' => $this->typeLabel($type), @@ -49,11 +67,13 @@ class ProductionDiagnosisService $operatorCode = (string) $entity->agent_code; $operatorAgent = $this->findAgent($operatorCode); $checks = []; + $pfpContext = null; $checks['status'] = $this->checkStatus($type, $entity); if ($type === self::TYPE_CASE) { - $checks['need_pfp'] = $this->checkNeedPfp($entity); + $pfpContext = $this->buildPfpContext($entity, $operatorCode); + $checks['need_pfp'] = $this->checkNeedPfp($pfpContext); } $checks['owner_agent'] = $this->checkOwnerAgent($type, $entity, $operatorCode); @@ -66,7 +86,7 @@ class ProductionDiagnosisService 'type_label' => $this->typeLabel($type), 'code' => $code, 'found' => true, - 'entity' => $this->normalizeEntity($type, $entity), + 'entity' => $this->normalizeEntity($type, $entity, $pfpContext), 'operating_agent_code' => $operatorCode, 'operating_agent' => $operatorAgent ? [ 'code' => (string) $operatorAgent->code, @@ -144,29 +164,301 @@ class ProductionDiagnosisService } /** - * 病例放行检查 - case.is_need_pfp > 0 + * 进产原因检查 - case.is_need_pfp 位图 + * + * is_need_pfp 来源于 CRM 的 ea_case_cstm.label_bit,经 stuck_payment_reason + * 配置过滤后写入代理库;只有存在卡款原因的病例才会走代理端进产流程。 + * + * @param array $ctx buildPfpContext 的返回值 */ - private function checkNeedPfp(object $caseEntity): array + private function checkNeedPfp(array $ctx): array { - $isNeedPfp = (int) ($caseEntity->is_need_pfp ?? 0); - $isPfp = (int) ($caseEntity->is_pfp ?? 0); - $pass = $isNeedPfp > 0; + $pass = $ctx['is_need_pfp'] > 0; + $reasonText = $ctx['reason_text']; - $detail = $pass - ? '病例 is_need_pfp = '.$isNeedPfp.',命中放行节点' - : '病例 is_need_pfp = 0,不需要放行(非欠款病例,不会触发进产流程)'; + $actual = $pass + ? sprintf('进产原因:%s(is_need_pfp = %d)', $reasonText, $ctx['is_need_pfp']) + : sprintf('无进产原因(is_need_pfp = 0);放行状态:%s', $ctx['is_pfp_text']); return [ 'key' => 'need_pfp', - 'label' => '放行节点检查 (is_need_pfp)', + 'label' => '进产原因检查', 'pass' => $pass, - 'expected' => 'is_need_pfp > 0', - 'actual' => 'is_need_pfp = '.$isNeedPfp.', is_pfp = '.$isPfp, - 'detail' => $detail, - 'hint' => $pass ? null : '检查 stuck_payment_reason 配置以及病例账期推算逻辑', + 'expected' => '病例存在卡生产原因('.$this->configOptionText($ctx).')', + 'actual' => $actual, + 'detail' => $this->pfpDetail($ctx, $pass), + 'hint' => $pass ? null : $this->pfpHint($ctx), + ] + $ctx; + } + + /** + * 汇总进产原因所需的全部上下文:代理库位图、配置项、CRM 原始 label_bit + * + * @return array + */ + private function buildPfpContext(object $caseEntity, string $operatorCode): array + { + $isNeedPfp = (int) ($caseEntity->is_need_pfp ?? 0); + $isPfp = (int) ($caseEntity->is_pfp ?? 0); + + $config = $this->stuckPaymentReasonConfig($operatorCode); + $reasons = $this->describeReasons($isNeedPfp, $config['reasons']); + + $crmLabelBit = $this->crmLabelBit((string) $caseEntity->case_code); + $crmAvailable = $crmLabelBit !== null; + $expectedNeedPfp = $crmAvailable ? ($crmLabelBit & $config['mask']) : null; + + // CRM 上有卡款标记,但对应的 bit 没有配进 stuck_payment_reason,代理端会直接忽略 + $ignoredBits = $crmAvailable + ? array_values(array_filter( + CaseLabelBit::split($crmLabelBit & ~$config['mask']), + static fn (int $bit): bool => $bit !== CaseLabelBit::ALLOW_PROCESS_BY_HONEST + )) + : []; + + return [ + 'is_need_pfp' => $isNeedPfp, + 'is_pfp' => $isPfp, + 'is_pfp_text' => $isPfp > 0 ? '已放行' : '未放行', + 'reasons' => $reasons, + 'reason_text' => $reasons === [] ? '无' : implode(' / ', array_column($reasons, 'label')), + 'config_mask' => $config['mask'], + 'config_source' => $config['source'], + 'config_source_text' => $this->configSourceText($config['source']), + 'config_options' => array_map( + static fn (int $bit, string $label): array => ['bit' => $bit, 'label' => $label], + array_keys($config['reasons']), + array_values($config['reasons']) + ), + 'crm_available' => $crmAvailable, + 'crm_label_bit' => $crmLabelBit, + 'crm_label_bit_text' => $crmAvailable ? CaseLabelBit::toText($crmLabelBit) : '未知(CRM 库不可读)', + 'crm_ignored_reasons' => array_map( + static fn (int $bit): array => [ + 'bit' => $bit, + 'label' => CaseLabelBit::crmLabel($bit), + 'description' => CaseLabelBit::description($bit), + ], + $ignoredBits + ), + 'expected_is_need_pfp' => $expectedNeedPfp, + 'sync_mismatch' => $expectedNeedPfp !== null && $expectedNeedPfp !== $isNeedPfp, ]; } + /** + * 把位图翻译成用户可读的进产原因 + * + * @param array $configReasons + * @return array> + */ + private function describeReasons(int $bitmap, array $configReasons): array + { + return array_map( + static fn (int $bit): array => [ + 'bit' => $bit, + 'label' => $configReasons[$bit] ?? CaseLabelBit::crmLabel($bit), + 'crm_label' => CaseLabelBit::crmLabel($bit), + 'description' => CaseLabelBit::description($bit), + ], + CaseLabelBit::split($bitmap) + ); + } + + /** + * 读取 agent-be configs 表中的 stuck_payment_reason + * + * 复现 ConfigService::getOne 的取值顺序:代理自身配置 → 全局配置 → 兜底默认值。 + * + * @return array{reasons: array, mask: int, source: string} + */ + private function stuckPaymentReasonConfig(string $operatorCode): array + { + try { + $rows = DB::connection($this->connection) + ->table('configs') + ->where('key', self::STUCK_PAYMENT_REASON_KEY) + ->whereIn('agent_code', array_values(array_unique([$operatorCode, '']))) + ->get(); + + $row = $rows->firstWhere('agent_code', $operatorCode) ?: $rows->firstWhere('agent_code', ''); + $reasons = $this->parseStuckPaymentReasons($row->val ?? null); + + if ($reasons !== []) { + return [ + 'reasons' => $reasons, + 'mask' => $this->maskOf($reasons), + 'source' => ((string) ($row->agent_code ?? '')) === '' ? 'global' : 'agent', + ]; + } + } catch (\Throwable $e) { + Log::warning('读取 stuck_payment_reason 配置失败,使用默认卡款原因。', ['exception' => $e]); + } + + return [ + 'reasons' => self::DEFAULT_STUCK_PAYMENT_REASONS, + 'mask' => $this->maskOf(self::DEFAULT_STUCK_PAYMENT_REASONS), + 'source' => 'default', + ]; + } + + /** + * configs.val 形如 [{"key":2,"lable":"新病例进产"},{"key":4,"lable":"转产品"}] + * 线上配置的 label 字段存在 lable 拼写,两种都兼容 + * + * @return array + */ + private function parseStuckPaymentReasons(mixed $val): array + { + if (is_string($val)) { + $val = json_decode($val, true); + } + + if (! is_array($val)) { + return []; + } + + $reasons = []; + foreach ($val as $item) { + $bit = (int) (is_array($item) ? ($item['key'] ?? 0) : 0); + if ($bit <= 0) { + continue; + } + $label = (string) ($item['lable'] ?? $item['label'] ?? ''); + $reasons[$bit] = $label !== '' ? $label : CaseLabelBit::crmLabel($bit); + } + + return $reasons; + } + + /** + * 复现 DebtEnum::needMoney - 所有配置项 key 的按位或 + * + * @param array $reasons + */ + private function maskOf(array $reasons): int + { + $mask = 0; + foreach (array_keys($reasons) as $bit) { + $mask |= $bit; + } + + return $mask; + } + + /** + * 直查 CRM 库的 ea_case_cstm.label_bit,用于判断代理库是否同步到位 + */ + private function crmLabelBit(string $caseCode): ?int + { + if ($caseCode === '') { + return null; + } + + try { + $value = DB::connection($this->crmConnection) + ->table('ea_case as c') + ->join('ea_case_cstm as cc', 'cc.id_c', '=', 'c.id') + ->where('c.name', $caseCode) + ->where('c.deleted', 0) + ->value('cc.label_bit'); + + return $value === null ? null : (int) $value; + } catch (\Throwable $e) { + Log::warning('读取 CRM label_bit 失败,跳过同步比对。', ['case_code' => $caseCode, 'exception' => $e]); + + return null; + } + } + + /** + * @param array $ctx + */ + private function pfpDetail(array $ctx, bool $pass): string + { + if ($pass) { + $detail = sprintf('病例因「%s」被卡在生产前,需要代理确认进产后才会放行。', $ctx['reason_text']); + + if ($ctx['is_pfp'] > 0) { + $detail .= '该病例已放行(is_pfp = 1)。'; + } + + if ($ctx['sync_mismatch']) { + $detail .= sprintf( + '注意:CRM 当前 label_bit = %d,按配置应为 is_need_pfp = %d,与代理库不一致。', + $ctx['crm_label_bit'], + $ctx['expected_is_need_pfp'] + ); + } + + return $detail; + } + + if ($ctx['sync_mismatch'] && $ctx['expected_is_need_pfp'] > 0) { + return sprintf( + 'CRM 已标记「%s」,按配置应写入 is_need_pfp = %d,但代理库仍为 0,疑似 case_basic_info_change 事件未消费或延迟。', + CaseLabelBit::toText($ctx['expected_is_need_pfp']), + $ctx['expected_is_need_pfp'] + ); + } + + if ($ctx['crm_ignored_reasons'] !== []) { + return sprintf( + 'CRM 标记了「%s」,但该原因未纳入 stuck_payment_reason 配置(当前仅 %s),代理端不会产生进产原因。', + implode(' / ', array_column($ctx['crm_ignored_reasons'], 'label')), + $this->configOptionText($ctx) + ); + } + + if ($ctx['crm_available'] && $ctx['crm_label_bit'] === 0) { + return '病例在 CRM 侧没有任何卡生产标记,属于正常病例,不需要也无法走代理进产流程。'; + } + + return '病例没有卡生产原因(is_need_pfp = 0),不需要代理放行,进产流程不会对该病例生效。'; + } + + /** + * @param array $ctx + */ + private function pfpHint(array $ctx): string + { + if ($ctx['sync_mismatch'] && $ctx['expected_is_need_pfp'] > 0) { + return '检查 agent-be 是否正常消费 CRM 的病例变更事件,必要时重新推送该病例的 case_basic_info_change 消息'; + } + + if ($ctx['crm_ignored_reasons'] !== []) { + return '若该原因也需要代理放行,需在 agent-be configs 表的 stuck_payment_reason 中补充对应 key'; + } + + if (! $ctx['crm_available']) { + return '未能读取 CRM 的 ea_case_cstm.label_bit,可检查 crmslave 数据库配置后重新诊断'; + } + + return '确认该病例是否确实需要卡款放行;正常病例由 CRM 直接进产,无需代理操作'; + } + + /** + * @param array $ctx + */ + private function configOptionText(array $ctx): string + { + $options = array_map( + static fn (array $option): string => sprintf('%s(%d)', $option['label'], $option['bit']), + $ctx['config_options'] + ); + + return $options === [] ? '未配置任何卡款原因' : implode('、', $options); + } + + private function configSourceText(string $source): string + { + return match ($source) { + 'agent' => '代理级 configs 配置', + 'global' => '全局 configs 配置', + default => '内置默认配置(未读到 configs 表)', + }; + } + /** * 归属代理检查 - 必须满足: * 1) entity.agent_code === operatorCode @@ -190,7 +482,7 @@ class ProductionDiagnosisService $detail = match (true) { $entityAgentCode === '' => '归属代理 agent_code 为空,无法定位操作代理', $entityAgentCode !== $operatorCode => '当前操作代理 '.$operatorCode.' 与单据归属代理 '.$entityAgentCode.' 不一致', - !$exists => '结算代理表 '.$settlementTable.' 中未找到 code='.$entityCode.', agent_code='.$operatorCode.' 的有效记录', + ! $exists => '结算代理表 '.$settlementTable.' 中未找到 code='.$entityCode.', agent_code='.$operatorCode.' 的有效记录', default => '操作代理为归属代理,且在结算代理表中存在有效记录', }; @@ -219,7 +511,7 @@ class ProductionDiagnosisService { $productCode = (string) ($entity->product_code ?? ''); - if (!$operatorAgent) { + if (! $operatorAgent) { return [ 'key' => 'credit', 'label' => '账期检查', @@ -249,7 +541,7 @@ class ProductionDiagnosisService ->where('deleted', 0) ->first(); - if (!$relation) { + if (! $relation) { return [ 'key' => 'credit', 'label' => '账期检查', @@ -303,9 +595,9 @@ class ProductionDiagnosisService $pass = $lastCreditAgentCode !== '' && $lastCreditAgentCode === $operatorCode; $detail = match (true) { - !$crmConfigured => 'CRM 接口未配置(CRM_SERVICE_BASE_URI),一级代理账期视为「未知」,链路计算可能与生产不一致', + ! $crmConfigured => 'CRM 接口未配置(CRM_SERVICE_BASE_URI),一级代理账期视为「未知」,链路计算可能与生产不一致', $firstAgentCreditMap === null => '调用 CRM 一级代理详情失败,无法判断一级代理账期', - !$firstAgentHasCredit => '一级代理 '.$rootAgentCode.' 在产品 '.$productCode.' 上无账期,AgentCredit 返回空,账期判断必然失败', + ! $firstAgentHasCredit => '一级代理 '.$rootAgentCode.' 在产品 '.$productCode.' 上无账期,AgentCredit 返回空,账期判断必然失败', $lastCreditAgentCode === '' => '账期链路计算结果为空', $pass => '最后一级有账期的代理 = 单据归属代理('.$operatorCode.')', default => '最后一级有账期的代理为 '.$lastCreditAgentCode.',与单据归属代理 '.$operatorCode.' 不一致', @@ -348,7 +640,7 @@ class ProductionDiagnosisService 'credit_source' => 'crm:getAgentByCode', ]; - if (!$firstAgentHasCredit) { + if (! $firstAgentHasCredit) { return [ 'last_credit_agent_code' => '', 'chain' => $chainView, @@ -377,8 +669,9 @@ class ProductionDiagnosisService continue; } - if (!$hasCredit) { + if (! $hasCredit) { $broken = true; + continue; } @@ -520,7 +813,10 @@ class ProductionDiagnosisService return $type === self::TYPE_CASE ? (string) $entity->case_code : (string) $entity->code; } - private function normalizeEntity(string $type, object $entity): array + /** + * @param array|null $pfpContext + */ + private function normalizeEntity(string $type, object $entity, ?array $pfpContext = null): array { $base = [ 'status' => (int) $entity->status, @@ -538,6 +834,9 @@ class ProductionDiagnosisService 'patient_name' => (string) ($entity->patient_name ?? ''), 'is_need_pfp' => (int) ($entity->is_need_pfp ?? 0), 'is_pfp' => (int) ($entity->is_pfp ?? 0), + 'debt_reason_text' => $pfpContext['reason_text'] ?? '无', + 'is_pfp_text' => $pfpContext['is_pfp_text'] ?? ((int) ($entity->is_pfp ?? 0) > 0 ? '已放行' : '未放行'), + 'crm_label_bit_text' => $pfpContext['crm_label_bit_text'] ?? '未知', ]; } @@ -549,13 +848,13 @@ class ProductionDiagnosisService private function creditHint(bool $crmConfigured, ?array $firstAgentCreditMap, bool $firstAgentHasCredit): string { - if (!$crmConfigured) { + if (! $crmConfigured) { return '配置 .env 中的 CRM_SERVICE_BASE_URI 后可获得准确的一级代理账期判断'; } if ($firstAgentCreditMap === null) { return 'CRM 接口调用失败,可查看 laravel.log'; } - if (!$firstAgentHasCredit) { + if (! $firstAgentHasCredit) { return '需在 CRM 「集团详情」productList 中确认该产品的 agentAccountingPeriod > 0'; } diff --git a/app/Services/ScheduledTaskService.php b/app/Services/ScheduledTaskService.php index 96d2233..431592e 100644 --- a/app/Services/ScheduledTaskService.php +++ b/app/Services/ScheduledTaskService.php @@ -23,6 +23,7 @@ class ScheduledTaskService try { self::$configServiceInstance ??= app(ConfigService::class); $enabled = self::$configServiceInstance->get(self::CONFIG_KEY, []); + return $enabled[$name] ?? false; } catch (\Exception $e) { return false; @@ -70,7 +71,7 @@ class ScheduledTaskService } } - if (!$exists) { + if (! $exists) { throw new \InvalidArgumentException("未知任务: {$name}"); } @@ -111,8 +112,10 @@ class ScheduledTaskService if (str_contains($command, 'artisan')) { $command = preg_replace('/^.*artisan\s+/', '', $command); } + return trim(str_replace("'", '', $command)); } + return 'closure'; } @@ -132,9 +135,11 @@ class ScheduledTaskService '0 0 * * *' => '每天凌晨 0:00', '0 2 * * *' => '每天凌晨 2:00', '0 3 * * *' => '每天凌晨 3:00', + '0 8 * * *' => '每天早上 08:00', '0 0 * * 0' => '每周日凌晨', '0 0 1 * *' => '每月 1 日凌晨', ]; + return $map[$expression] ?? $expression; } @@ -149,9 +154,11 @@ class ScheduledTaskService 'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志', 'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志', 'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知', + 'erp-request-report' => 'ERP 请求日报 - 汇总前一天 OpenAPI 请求并发送钉钉', 'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表', 'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志', ]; + return $descriptions[$name] ?? $name; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index a7c3878..ddbdbf9 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,8 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->append(\App\Http\Middleware\HostAccessMiddleware::class); + $middleware->alias([ 'admin.ip' => \App\Http\Middleware\AdminIpMiddleware::class, ]); diff --git a/config/logging.php b/config/logging.php index 2b1de4e..cb1d9b7 100644 --- a/config/logging.php +++ b/config/logging.php @@ -135,6 +135,14 @@ return [ 'replace_placeholders' => true, ], + 'erp-request-report' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/scheduled-tasks/erp-request-report.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => 7, + 'replace_placeholders' => true, + ], + 'git-monitor' => [ 'driver' => 'daily', 'path' => storage_path('logs/scheduled-tasks/git-monitor.log'), diff --git a/config/toolbox.php b/config/toolbox.php index 5cd89d8..9142ca2 100644 --- a/config/toolbox.php +++ b/config/toolbox.php @@ -1,8 +1,9 @@ strtolower((string) env('TOOLBOX_ADMIN_HOST', 'toolbox.local')), 'admin_ips' => array_values(array_filter(array_map( - static fn(string $ip): string => trim($ip), + static fn (string $ip): string => trim($ip), explode(',', (string) env('TOOLBOX_ADMIN_IPS', '')) ))), 'operation_log' => [ diff --git a/phpunit.xml b/phpunit.xml index 5fd5bcf..2558d97 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,5 +30,6 @@ + diff --git a/resources/js/components/admin/JenkinsBuilds.vue b/resources/js/components/admin/JenkinsBuilds.vue index 7f14b31..44e4c4e 100644 --- a/resources/js/components/admin/JenkinsBuilds.vue +++ b/resources/js/components/admin/JenkinsBuilds.vue @@ -6,12 +6,21 @@

勾选 Jenkins 通知项目,调整参数后批量触发 Build

+
+ + + + 正在刷新 +
+ +
+ +
+ {{ record.project_slug || '-' }} + {{ record.project_name }} +
+
+ project + {{ record.project_parameter || '-' }} + 构建号 + {{ record.build_number ? `#${record.build_number}` : '-' }} +
+
+ {{ record.message }} +
+ + +