Compare commits

...
Author SHA1 Message Date
tradewind 103340536b #feature: some update 2026-08-12 18:00:09 +08:00
tradewind ba916f5ce1 #feature: add Jenkins batch build trigger
Add an admin Jenkins build page that reuses notification-enabled project config, loads Jenkins build parameters, supports persisted selections, and triggers selected jobs in one action.
2026-06-01 18:03:33 +08:00
tradewind 1de5fe1ff9 #bugfix: sync operation log user labels 2026-06-01 14:35:23 +08:00
tradewind 4875031cc3 #feature: improve Jira planning and release branch creation 2026-05-29 14:25:43 +08:00
tradewind ade18a0aa8 #bugfix: include Jira account-owned bugs in reports 2026-05-29 14:24:01 +08:00
tradewind 787a69c207 #feature: add test mail generator 2026-05-22 10:10:16 +08:00
tradewind 3c628eb391 #feature: update SQL generator 2026-05-19 14:57:11 +08:00
tradewind 53bca7d609 #feature: update AI log analysis 2026-02-11 11:00:32 +08:00
tradewind ddd0f531fd #feature: update log format 2026-01-19 15:42:44 +08:00
tradewind 0646c8612b #feature: update log format 2026-01-19 14:21:15 +08:00
tradewind da3b05b7c0 #feature: add Jenkins deploy monitor & log clean task 2026-01-19 11:46:38 +08:00
86 changed files with 8950 additions and 617 deletions
+20
View File
@@ -101,10 +101,15 @@ AGENT_TIMEOUT=30
MONO_URL=http://localhost:8081 MONO_URL=http://localhost:8081
MONO_TIMEOUT=30 MONO_TIMEOUT=30
# CRM Service Configuration (用于进产诊断中的一级代理账期判断)
CRM_SERVICE_BASE_URI=
CRM_SERVICE_TIMEOUT=15
# Git Monitor Configuration # Git Monitor Configuration
GIT_MONITOR_PROJECTS="service,portal-be,agent-be" GIT_MONITOR_PROJECTS="service,portal-be,agent-be"
# Admin IP whitelist (comma separated, supports wildcard: 192.168.* or 192.168.1.*) # Admin IP whitelist (comma separated, supports wildcard: 192.168.* or 192.168.1.*)
TOOLBOX_ADMIN_HOST=toolbox.local
TOOLBOX_ADMIN_IPS= TOOLBOX_ADMIN_IPS=
# Alibaba Cloud SLS Configuration # Alibaba Cloud SLS Configuration
@@ -126,3 +131,18 @@ AI_TEMPERATURE=0.3
AI_TIMEOUT=120 AI_TIMEOUT=120
AI_MAX_TOKENS=4096 AI_MAX_TOKENS=4096
# Gemini CLI Configuration (用于代码分析)
# 获取 API Key: https://aistudio.google.com/apikey
GEMINI_API_KEY=
# Proxy Configuration (用于后台任务访问外网)
PROXY_URL=
DINGTALK_WEBHOOK=
DINGTALK_SECRET=
# Jenkins Configuration
JENKINS_HOST=http://jenkins.example.com
JENKINS_USERNAME=
JENKINS_API_TOKEN=
JENKINS_TIMEOUT=30
Binary file not shown.
+124 -38
View File
@@ -1,17 +1,10 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: php
# whether to use the project's gitignore file to ignore files # whether to use project's .gitignore files to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and ** # list of additional paths to ignore in this project.
# Was previously called `ignored_dirs`, please update your config if you are using that. # Same syntax as gitignore, so you can use * and **.
# Added (renamed) on 2025-04-07 # Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: [] ignored_paths: []
# whether the project is in read-only mode # whether the project is in read-only mode
@@ -19,50 +12,143 @@ ignored_paths: []
# Added on 2025-04-18 # Added on 2025-04-18
read_only: false read_only: false
# list of tool names to exclude.
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. # This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience. # Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions, # To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`. # execute `uv run scripts/print_tool_overview.py`.
# #
# * `activate_project`: Activates a project by name. # * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed. # * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory. # * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file. # * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. # for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command. # * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. # * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). # * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). # * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project. # * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# Should only be used in settings where the system prompt cannot be set, # for clients that do not read the initial instructions when the MCP server is connected.
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). # * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store. # * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory. # * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. # * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# * `remove_project`: Removes a project from the Serena configuration. # is relevant to the current task. You can infer whether the information
# * `replace_lines`: Replaces a range of lines within a file with new content. # is relevant from the memory file name.
# * `replace_symbol_body`: Replaces the full definition of a symbol. # You should not read the same memory file multiple times in the same conversation.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. # * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project. # * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. # * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# * `switch_modes`: Activates modes by providing a list of their names # The memory name should be meaningful.
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: [] excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project # initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: "" initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "toolbox" project_name: "toolbox"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: utf-8
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- php
+88
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
Tradewind Toolbox 是一个基于 Laravel 12 的内部工具管理平台,提供 Vue 3 单页应用前端和 RESTful API 后端。主要功能模块包括:
- **环境管理** - .env 文件的保存、应用、备份、恢复
- **JIRA 集成** - 周报生成、工时日志查询
- **消息同步** - 跨系统消息队列同步和对比
- **消息分发** - 消息路由配置管理
- **日志分析** - 阿里云 SLS 日志查询 + AI 分析
- **Git 监控** - Release 分支检查、冲突检测
- **Jenkins 监控** - 构建状态监控和钉钉通知
## 常用命令
```bash
# 开发环境(同时启动后端、队列、日志、前端)
composer dev
# 运行测试
composer test
# PHP 代码格式化
./vendor/bin/pint
# 数据库迁移
php artisan migrate
# 清除缓存
php artisan optimize:clear
# 前端构建
npm run build
```
## 核心架构
### 服务层 (`app/Services/`)
业务逻辑集中在 Services 目录,所有服务在 `AppServiceProvider` 中注册为单例:
| 服务 | 职责 |
|------|------|
| `ConfigService` | 数据库键值配置存储 |
| `JiraService` | JIRA REST API 集成 |
| `SlsService` | 阿里云 SLS 日志查询 |
| `AiService` | AI 提供商管理(支持 OpenAI 兼容接口) |
| `LogAnalysisService` | 日志分析编排(SLS → AI → 代码分析) |
| `CodeAnalysisService` | 代码级分析(调用 Gemini/Claude CLI |
| `GitMonitorService` | Git 仓库监控 |
| `JenkinsMonitorService` | Jenkins 构建监控 |
| `DingTalkService` | 钉钉 Webhook 通知 |
| `EnvService` | .env 文件管理 |
| `ScheduledTaskService` | 定时任务动态控制 |
### 外部客户端 (`app/Clients/`)
封装外部服务调用:`AiClient``SlsClient``JenkinsClient``AgentClient``MonoClient`
### 定时任务 (`routes/console.php`)
所有定时任务可在管理后台动态启用/禁用,状态存储在 `configs` 表:
- `git-monitor:check` - 每 10 分钟检查 release 分支
- `git-monitor:cache` - 每天 02:00 刷新 release 缓存
- `log-analysis:run` - 每天 02:00 执行日志+代码分析
- `jenkins:monitor` - 每分钟检查 Jenkins 构建
### 队列任务 (`app/Jobs/`)
`LogAnalysisJob` - 后台执行日志分析:获取日志 → 按 app 分组 → AI 分析 → 代码分析 → 保存报告 → 推送通知
### 路由结构
- **Web 路由** (`routes/web.php`) - 所有页面通过 `AdminController@index` 渲染 Vue SPA
- **API 路由** (`routes/api.php`) - RESTful API,按模块分组(env、jira、log-analysis、admin 等)
- **中间件** - `AdminIpMiddleware` IP 白名单、`OperationLogMiddleware` 操作审计
## 技术栈
- **后端**: PHP 8.2+, Laravel 12, PHPUnit 11
- **前端**: Vue 3, Vite 7, Tailwind CSS 4, CodeMirror 6
- **数据库**: SQLite (默认) / MySQL
- **队列**: Database 驱动
- **外部集成**: JIRA、阿里云 SLS、OpenAI 兼容 API、钉钉、Jenkins
+1 -1
View File
@@ -140,7 +140,7 @@ class AiClient
]); ]);
if ($response->successful()) { if ($response->successful()) {
return $response->json('choices.0.message.content', ''); return $response->json('choices.0.message.content') ?? '';
} }
// 处理 429 Too Many Requests 错误 // 处理 429 Too Many Requests 错误
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Clients;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* CRM Service HTTP 客户端
*
* 用于调用 agent-be 依赖的 CRM 服务接口,主要用于进产诊断中的一级代理账期判断。
*/
class CrmClient
{
private string $baseUrl;
private int $timeout;
public function __construct()
{
$this->baseUrl = rtrim((string) config('services.crm.base_uri'), '/');
$this->timeout = (int) config('services.crm.timeout', 15);
}
/**
* 是否已配置 CRM 接口
*/
public function isConfigured(): bool
{
return $this->baseUrl !== '';
}
/**
* 获取一级代理详情,包含 productList[].productCode / agentAccountingPeriod
*
* 对应 agent-be Client::getAgentByCode -> GET /api/group/detail/{code}
*
* @return array|null 成功返回响应 JSON 解码数据;失败返回 null
*/
public function getAgentByCode(string $rootAgentCode): ?array
{
if (!$this->isConfigured()) {
return null;
}
try {
$response = $this->http()->get($this->baseUrl.'/api/group/detail/'.$rootAgentCode);
if (!$response->successful()) {
Log::warning('CRM getAgentByCode non-2xx', [
'code' => $rootAgentCode,
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
return $response->json();
} catch (\Throwable $e) {
Log::warning('CRM getAgentByCode failed', [
'code' => $rootAgentCode,
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* 解析 getAgentByCode 返回,构建 productCode => hasCredit 的映射
*/
public function firstAgentCreditMap(string $rootAgentCode): array
{
$data = $this->getAgentByCode($rootAgentCode);
if (!is_array($data)) {
return [];
}
// 与 agent-be AgentCredit::getFirstAgentCredit 保持一致:要求 code == 200
if (!isset($data['code']) || (int) $data['code'] !== 200) {
return [];
}
$productList = $data['data']['productList'] ?? [];
$map = [];
foreach ($productList as $product) {
$productCode = (string) ($product['productCode'] ?? '');
if ($productCode === '') {
continue;
}
$map[$productCode] = ((int) ($product['agentAccountingPeriod'] ?? 0)) > 0;
}
return $map;
}
private function http(): PendingRequest
{
return Http::timeout($this->timeout)
->withoutVerifying()
->acceptJson();
}
}
+556
View File
@@ -0,0 +1,556 @@
<?php
namespace App\Clients;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class JenkinsClient
{
private ?string $host;
private ?string $username;
private ?string $apiToken;
private int $timeout;
public function __construct()
{
$config = config('jenkins', []);
$this->host = rtrim($config['host'] ?? '', '/');
$this->username = $config['username'] ?? null;
$this->apiToken = $config['api_token'] ?? null;
$this->timeout = $config['timeout'] ?? 30;
}
public function isConfigured(): bool
{
return ! empty($this->host) && ! empty($this->username) && ! empty($this->apiToken);
}
public function getJobInfo(string $jobName): ?array
{
return $this->request($this->getJobPath($jobName).'/api/json');
}
public function getBuildInfo(string $jobName, int $buildNumber): ?array
{
return $this->request($this->getJobPath($jobName)."/{$buildNumber}/api/json");
}
public function getLastBuild(string $jobName): ?array
{
return $this->request($this->getJobPath($jobName).'/lastBuild/api/json');
}
public function getParameterDefinitions(string $jobName): array
{
$jobInfo = $this->getJobInfo($jobName);
if (! $jobInfo || empty($jobInfo['property'])) {
return [];
}
$buildFormParameters = $this->getBuildFormParameters($jobName);
foreach ($jobInfo['property'] as $property) {
if (empty($property['parameterDefinitions']) || ! is_array($property['parameterDefinitions'])) {
continue;
}
return array_map(function (array $definition) use ($buildFormParameters) {
$name = $definition['name'] ?? '';
$formParameter = $buildFormParameters[$name] ?? [];
$default = $definition['defaultParameterValue']['value'] ?? null;
if (($formParameter['multiple'] ?? false) && isset($formParameter['default'])) {
$default = $formParameter['default'];
}
return [
'name' => $name,
'type' => $definition['type'] ?? $definition['_class'] ?? 'StringParameterDefinition',
'description' => $definition['description'] ?? '',
'default' => $default,
'choices' => $definition['choices'] ?? $formParameter['choices'] ?? [],
'multiple' => (bool) ($formParameter['multiple'] ?? false),
];
}, array_values(array_filter($property['parameterDefinitions'], fn ($definition) => ! empty($definition['name']))));
}
return [];
}
public function triggerBuild(string $jobName, array $parameters = []): array
{
if (! $this->isConfigured()) {
Log::warning('Jenkins client is not configured');
return [
'success' => false,
'message' => 'Jenkins not configured',
];
}
$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) {
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
}
$response = empty($parameters)
? $request->post($url)
: $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(),
];
}
Log::warning('Jenkins build trigger failed', [
'url' => $url,
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'success' => false,
'message' => 'Jenkins 返回状态码 '.$response->status(),
'status' => $response->status(),
];
} catch (\Throwable $e) {
Log::error('Jenkins build trigger error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => $e->getMessage(),
];
}
}
public function getBuilds(string $jobName, int $limit = 10): array
{
$jobInfo = $this->getJobInfo($jobName);
if (! $jobInfo || empty($jobInfo['builds'])) {
return [];
}
$builds = array_slice($jobInfo['builds'], 0, $limit);
$result = [];
foreach ($builds as $build) {
$buildInfo = $this->getBuildInfo($jobName, $build['number']);
if ($buildInfo) {
$result[] = $buildInfo;
}
}
return $result;
}
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()) {
Log::warning('Jenkins client is not configured');
return null;
}
$url = $this->host.$path;
try {
$response = $this->http()->get($url);
if ($response->successful()) {
return $response->json();
}
Log::warning('Jenkins API request failed', [
'url' => $url,
'status' => $response->status(),
]);
return null;
} catch (\Throwable $e) {
Log::error('Jenkins API request error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return null;
}
}
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()) {
Log::warning('Jenkins client is not configured');
return null;
}
$url = $this->host.$path;
try {
$response = $this->http()->get($url);
if ($response->successful() || ($allowMethodNotAllowed && $response->status() === 405)) {
return $response->body();
}
Log::warning('Jenkins page request failed', [
'url' => $url,
'status' => $response->status(),
]);
return null;
} catch (\Throwable $e) {
Log::error('Jenkins page request error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return null;
}
}
private function getBuildFormParameters(string $jobName): array
{
$html = $this->requestBody($this->getJobPath($jobName).'/build?delay=0sec', allowMethodNotAllowed: true);
if (! $html) {
return [];
}
$parameters = [];
foreach (array_slice(preg_split('/<div name="parameter"[^>]*>/s', $html) ?: [], 1) as $parameterHtml) {
if (! preg_match('/<input name="name" type="hidden" value="([^"]+)"/s', $parameterHtml, $nameMatch)) {
continue;
}
if (! preg_match('/<select([^>]*)>(.*?)<\/select>/s', $parameterHtml, $selectMatch)) {
continue;
}
$name = html_entity_decode($nameMatch[1], ENT_QUOTES | ENT_HTML5);
$selectAttributes = $selectMatch[1];
$optionsHtml = $selectMatch[2];
$multiple = str_contains($selectAttributes, 'multiple');
preg_match_all('/<option([^>]*) value="([^"]*)"[^>]*>(.*?)<\/option>/s', $optionsHtml, $optionMatches, PREG_SET_ORDER);
$choices = [];
$defaults = [];
foreach ($optionMatches as $optionMatch) {
$attributes = $optionMatch[1];
$value = html_entity_decode($optionMatch[2], ENT_QUOTES | ENT_HTML5);
$label = trim(strip_tags(html_entity_decode($optionMatch[3], ENT_QUOTES | ENT_HTML5)));
$selected = str_contains($attributes, 'selected') || str_contains($label, '√');
$label = trim(str_replace('√', '', $label));
$choices[] = [
'value' => $value,
'label' => $label ?: $value,
'selected' => $selected,
];
if ($selected) {
$defaults[] = $value;
}
}
$parameters[$name] = [
'choices' => $choices,
'default' => $multiple ? $defaults : ($defaults[0] ?? null),
'multiple' => $multiple,
];
}
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)
->withBasicAuth($this->username, $this->apiToken);
}
private function getCrumb(): ?array
{
$crumb = $this->request('/crumbIssuer/api/json');
if (! $crumb || empty($crumb['crumb']) || empty($crumb['crumbRequestField'])) {
return null;
}
return [
'field' => $crumb['crumbRequestField'],
'crumb' => $crumb['crumb'],
];
}
private function getJobPath(string $jobName): string
{
$segments = array_filter(explode('/', trim($jobName, '/')), fn ($segment) => $segment !== '');
return '/job/'.implode('/job/', array_map('rawurlencode', $segments));
}
}
+10
View File
@@ -33,5 +33,15 @@ class MonoClient
{ {
return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/update-dispatch', $data); return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/update-dispatch', $data);
} }
/**
* 手动消费指定消息(由mono从CRM获取消息并进行分发)
*/
public function consumeMessage(string $msgId): Response
{
return $this->http->post($this->baseUrl . '/rpc/datadispatch/message/consume', [
'msg_id' => $msgId,
]);
}
} }
+25 -2
View File
@@ -82,6 +82,7 @@ class SlsClient
* @param int $offset 偏移量 * @param int $offset 偏移量
* @param int $limit 返回数量 * @param int $limit 返回数量
* @param string|null $logstore 可选的 logstore,不传则使用默认 * @param string|null $logstore 可选的 logstore,不传则使用默认
* @param int $maxRetries 最大重试次数
* @return array{logs: array, count: int, complete: bool} * @return array{logs: array, count: int, complete: bool}
*/ */
public function getLogs( public function getLogs(
@@ -90,7 +91,8 @@ class SlsClient
?string $query = null, ?string $query = null,
int $offset = 0, int $offset = 0,
int $limit = 100, int $limit = 100,
?string $logstore = null ?string $logstore = null,
int $maxRetries = 3
): array { ): array {
$this->ensureConfigured(); $this->ensureConfigured();
@@ -106,6 +108,8 @@ class SlsClient
false false
); );
$lastException = null;
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try { try {
$response = $this->client->getLogs($request); $response = $this->client->getLogs($request);
@@ -120,14 +124,33 @@ class SlsClient
'complete' => $response->isCompleted(), 'complete' => $response->isCompleted(),
]; ];
} catch (Aliyun_Log_Exception $e) { } catch (Aliyun_Log_Exception $e) {
$lastException = $e;
$errorCode = $e->getErrorCode();
// 对于 5xx 错误或 RequestError,进行重试
if (str_contains($errorCode, 'RequestError') || str_contains($e->getErrorMessage(), '50')) {
if ($attempt < $maxRetries) {
sleep(pow(2, $attempt)); // 指数退避: 2, 4, 8 秒
continue;
}
}
// 其他错误直接抛出
throw new RuntimeException( throw new RuntimeException(
"SLS 查询失败: [{$e->getErrorCode()}] {$e->getErrorMessage()}", "SLS 查询失败: [{$errorCode}] {$e->getErrorMessage()}",
0, 0,
$e $e
); );
} }
} }
throw new RuntimeException(
"SLS 查询失败: [{$lastException->getErrorCode()}] {$lastException->getErrorMessage()}",
0,
$lastException
);
}
/** /**
* 获取日志分布直方图 * 获取日志分布直方图
* *
@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\ErpRequestReportService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ErpRequestReportCommand extends Command
{
protected $signature = 'erp-request-report:send
{--date= : 统计单日(Y-m-d;与 --from/--to 互斥,默认昨天)}
{--from= : 开始时间(Y-m-d Y-m-d H:i:s,含)}
{--to= : 结束时间(Y-m-d Y-m-d H:i:s;仅日期时含整天)}';
protected $description = '汇总指定时间段 ERP OpenAPI 请求并发送钉钉日报(默认昨天)';
public function handle(ErpRequestReportService $service): int
{
try {
$result = $service->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;
}
}
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Services\GitMonitorService; use App\Services\GitMonitorService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class GitMonitorCacheCommand extends Command class GitMonitorCacheCommand extends Command
{ {
@@ -16,11 +17,11 @@ class GitMonitorCacheCommand extends Command
$cache = $monitor->refreshReleaseCache(true); $cache = $monitor->refreshReleaseCache(true);
if (empty($cache)) { if (empty($cache)) {
$this->warn('未获取到任何 release 版本信息,请检查配置。'); Log::channel('git-monitor')->warning('未获取到任何 release 版本信息,请检查配置。');
return; return;
} }
$this->info(sprintf( Log::channel('git-monitor')->info(sprintf(
'已缓存 %d 个仓库的 release 分支信息。', '已缓存 %d 个仓库的 release 分支信息。',
count($cache['repositories'] ?? []) count($cache['repositories'] ?? [])
)); ));
@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Services\GitMonitorService; use App\Services\GitMonitorService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class GitMonitorCheckCommand extends Command class GitMonitorCheckCommand extends Command
{ {
@@ -24,11 +25,11 @@ class GitMonitorCheckCommand extends Command
foreach ($results as $repo => $result) { foreach ($results as $repo => $result) {
if (isset($result['error'])) { if (isset($result['error'])) {
$this->error(sprintf('[%s] %s', $repo, $result['error'])); Log::channel('git-monitor')->error(sprintf('[%s] %s', $repo, $result['error']));
continue; continue;
} }
$this->line(sprintf( Log::channel('git-monitor')->info(sprintf(
'[%s] 分支 %s 已对齐 %s,扫描 %d 个提交。', '[%s] 分支 %s 已对齐 %s,扫描 %d 个提交。',
$repo, $repo,
$result['branch'], $result['branch'],
@@ -37,9 +38,9 @@ class GitMonitorCheckCommand extends Command
)); ));
if (!empty($result['issues']['develop_merges'])) { if (!empty($result['issues']['develop_merges'])) {
$this->warn(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges']))); Log::channel('git-monitor')->warning(sprintf(' - 检测到 %d 个 develop merge:', count($result['issues']['develop_merges'])));
foreach ($result['issues']['develop_merges'] as $commit) { foreach ($result['issues']['develop_merges'] as $commit) {
$this->warn(sprintf( Log::channel('git-monitor')->warning(sprintf(
' • %s %s (%s)', ' • %s %s (%s)',
substr($commit['hash'], 0, 8), substr($commit['hash'], 0, 8),
$commit['subject'], $commit['subject'],
@@ -49,9 +50,9 @@ class GitMonitorCheckCommand extends Command
} }
if (!empty($result['issues']['missing_functions'])) { if (!empty($result['issues']['missing_functions'])) {
$this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions']))); Log::channel('git-monitor')->warning(sprintf(' - 检测到 %d 个疑似缺失函数的提交:', count($result['issues']['missing_functions'])));
foreach ($result['issues']['missing_functions'] as $issue) { foreach ($result['issues']['missing_functions'] as $issue) {
$this->warn(sprintf( Log::channel('git-monitor')->warning(sprintf(
' • %s %s (%s)', ' • %s %s (%s)',
substr($issue['commit']['hash'], 0, 8), substr($issue['commit']['hash'], 0, 8),
$issue['commit']['subject'], $issue['commit']['subject'],
@@ -59,7 +60,7 @@ class GitMonitorCheckCommand extends Command
)); ));
foreach ($issue['details'] as $detail) { foreach ($issue['details'] as $detail) {
$functions = implode(', ', array_slice($detail['functions'], 0, 5)); $functions = implode(', ', array_slice($detail['functions'], 0, 5));
$this->warn(sprintf(' %s => %s', $detail['file'], $functions)); Log::channel('git-monitor')->warning(sprintf(' %s => %s', $detail['file'], $functions));
} }
} }
} }
@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\JenkinsMonitorService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class JenkinsMonitorCommand extends Command
{
protected $signature = 'jenkins:monitor';
protected $description = '轮询 Jenkins 检查新构建并发送钉钉通知';
public function handle(JenkinsMonitorService $service): void
{
Log::channel('jenkins-monitor')->info('开始检查 Jenkins 构建...');
$results = $service->checkAllProjects();
if (isset($results['skipped'])) {
Log::channel('jenkins-monitor')->warning('跳过检查: ' . ($results['reason'] ?? 'unknown'));
return;
}
foreach ($results as $slug => $result) {
if (isset($result['skipped'])) {
Log::channel('jenkins-monitor')->info(sprintf('[%s] 跳过: %s', $slug, $result['reason'] ?? 'unknown'));
continue;
}
$newBuilds = $result['new_builds'] ?? [];
if (empty($newBuilds)) {
Log::channel('jenkins-monitor')->info(sprintf('[%s] 无新构建', $slug));
} else {
Log::channel('jenkins-monitor')->info(sprintf('[%s] 发现 %d 个新构建: #%s', $slug, count($newBuilds), implode(', #', $newBuilds)));
}
}
Log::channel('jenkins-monitor')->info('检查完成');
}
}
+33 -46
View File
@@ -8,6 +8,7 @@ use App\Services\SlsService;
use App\Services\AiService; use App\Services\AiService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class LogAnalysisCommand extends Command class LogAnalysisCommand extends Command
{ {
@@ -29,13 +30,13 @@ class LogAnalysisCommand extends Command
): int { ): int {
// 检查配置 // 检查配置
if (!$slsService->isConfigured()) { if (!$slsService->isConfigured()) {
$this->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项'); Log::channel('log-analysis')->error('SLS 服务未配置,请检查 .env 中的 SLS_* 配置项');
return Command::FAILURE; return self::FAILURE;
} }
if (!$aiService->isConfigured()) { if (!$aiService->isConfigured()) {
$this->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项'); Log::channel('log-analysis')->error('AI 服务未配置,请在页面上配置 AI 提供商或设置 .env 中的 AI_* 配置项');
return Command::FAILURE; return self::FAILURE;
} }
// 解析时间参数 // 解析时间参数
@@ -43,8 +44,8 @@ class LogAnalysisCommand extends Command
$to = $this->parseTime($this->option('to') ?? 'now'); $to = $this->parseTime($this->option('to') ?? 'now');
if ($from >= $to) { if ($from >= $to) {
$this->error('开始时间必须早于结束时间'); Log::channel('log-analysis')->error('开始时间必须早于结束时间');
return Command::FAILURE; return self::FAILURE;
} }
// 解析分析模式 // 解析分析模式
@@ -55,11 +56,10 @@ class LogAnalysisCommand extends Command
$query = $this->option('query'); $query = $this->option('query');
$this->info("开始分析日志..."); Log::channel('log-analysis')->info("开始分析日志...");
$this->line(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}"); Log::channel('log-analysis')->info(" 时间范围: {$from->format('Y-m-d H:i:s')} ~ {$to->format('Y-m-d H:i:s')}");
$this->line(" 查询语句: " . ($query ?: '*')); Log::channel('log-analysis')->info(" 查询语句: " . ($query ?: '*'));
$this->line(" 分析模式: {$mode->label()}"); Log::channel('log-analysis')->info(" 分析模式: {$mode->label()}");
$this->newLine();
try { try {
$result = $analysisService->analyze( $result = $analysisService->analyze(
@@ -74,27 +74,27 @@ class LogAnalysisCommand extends Command
if ($outputPath = $this->option('output')) { if ($outputPath = $this->option('output')) {
$json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); $json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
file_put_contents($outputPath, $json); file_put_contents($outputPath, $json);
$this->info("报告已保存到: {$outputPath}"); Log::channel('log-analysis')->info("报告已保存到: {$outputPath}");
} }
// 推送到钉钉 // 推送到钉钉
if ($this->option('push')) { if ($this->option('push')) {
$this->line("正在推送到钉钉..."); Log::channel('log-analysis')->info("正在推送到钉钉...");
$pushed = $analysisService->pushToNotification($result); $pushed = $analysisService->pushToNotification($result);
if ($pushed) { if ($pushed) {
$this->info("已推送到钉钉"); Log::channel('log-analysis')->info("已推送到钉钉");
} else { } else {
$this->warn("钉钉推送失败"); Log::channel('log-analysis')->warning("钉钉推送失败");
} }
} }
// 显示摘要 // 显示摘要
$this->displaySummary($result); $this->displaySummary($result);
return Command::SUCCESS; return self::SUCCESS;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error("分析失败: {$e->getMessage()}"); Log::channel('log-analysis')->error("分析失败: {$e->getMessage()}");
return Command::FAILURE; return self::FAILURE;
} }
} }
@@ -132,57 +132,44 @@ class LogAnalysisCommand extends Command
*/ */
private function displaySummary(array $result): void private function displaySummary(array $result): void
{ {
$this->newLine(); Log::channel('log-analysis')->info('=== 分析摘要 ===');
$this->info('=== 分析摘要 ==='); Log::channel('log-analysis')->info("总日志数: {$result['metadata']['total_logs']}");
$this->line("总日志数: {$result['metadata']['total_logs']}"); Log::channel('log-analysis')->info("分析应用数: {$result['metadata']['apps_analyzed']}");
$this->line("分析应用数: {$result['metadata']['apps_analyzed']}"); Log::channel('log-analysis')->info("执行时间: {$result['metadata']['execution_time_ms']}ms");
$this->line("执行时间: {$result['metadata']['execution_time_ms']}ms");
$this->newLine();
if (empty($result['results'])) { if (empty($result['results'])) {
$this->warn('未找到匹配的日志'); Log::channel('log-analysis')->warning('未找到匹配的日志');
return; return;
} }
foreach ($result['results'] as $appName => $appResult) { foreach ($result['results'] as $appName => $appResult) {
$this->line("{$appName}"); Log::channel('log-analysis')->info("{$appName}");
if (isset($appResult['error'])) { if (isset($appResult['error'])) {
$this->error(" 分析失败: {$appResult['error']}"); Log::channel('log-analysis')->error(" 分析失败: {$appResult['error']}");
continue; continue;
} }
$impact = $appResult['impact'] ?? 'unknown'; $impact = $appResult['impact'] ?? 'unknown';
$impactColor = match ($impact) {
'high' => 'red',
'medium' => 'yellow',
'low' => 'green',
default => 'white',
};
$this->line(" 日志数: {$appResult['log_count']}"); Log::channel('log-analysis')->info(" 日志数: {$appResult['log_count']}");
$this->line(" 代码上下文: " . ($appResult['has_code_context'] ? '是' : '否')); Log::channel('log-analysis')->info(" 影响级别: {$impact}");
$this->line(" 影响级别: <fg={$impactColor}>{$impact}</>"); Log::channel('log-analysis')->info(" 摘要: " . ($appResult['summary'] ?? 'N/A'));
$this->line(" 摘要: " . ($appResult['summary'] ?? 'N/A'));
$anomalies = $appResult['core_anomalies'] ?? []; $anomalies = $appResult['core_anomalies'] ?? [];
if (!empty($anomalies)) { if (!empty($anomalies)) {
$this->line(" 异常数: " . count($anomalies)); Log::channel('log-analysis')->info(" 异常数: " . count($anomalies));
$table = [];
foreach (array_slice($anomalies, 0, 5) as $anomaly) { foreach (array_slice($anomalies, 0, 5) as $anomaly) {
$table[] = [ Log::channel('log-analysis')->info(sprintf(
" - [%s] %s (数量: %d) - %s",
$anomaly['type'] ?? 'N/A', $anomaly['type'] ?? 'N/A',
$anomaly['classification'] ?? 'N/A', $anomaly['classification'] ?? 'N/A',
$anomaly['count'] ?? 1, $anomaly['count'] ?? 1,
mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40), mb_substr($anomaly['possible_cause'] ?? 'N/A', 0, 40)
]; ));
} }
$this->table(['类型', '分类', '数量', '可能原因'], $table);
} }
$this->newLine();
} }
} }
} }
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\ScheduledTaskService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ScheduledTaskRefreshCommand extends Command
{
protected $signature = 'scheduled-task:refresh';
protected $description = '刷新定时任务列表,同步 console.php 中的任务配置到数据库';
public function handle(ScheduledTaskService $taskService): int
{
try {
Log::channel('scheduled-tasks')->info('开始刷新定时任务列表...');
$tasks = $taskService->getAllTasks();
Log::channel('scheduled-tasks')->info(sprintf('成功刷新 %d 个定时任务', count($tasks)));
// 显示任务列表
foreach ($tasks as $task) {
Log::channel('scheduled-tasks')->info(sprintf(
' - %s: %s (%s) [%s]',
$task['name'],
$task['description'],
$task['frequency'],
$task['enabled'] ? '已启用' : '已禁用'
));
}
return Command::SUCCESS;
} catch (\Exception $e) {
Log::channel('scheduled-tasks')->error("刷新失败: {$e->getMessage()}");
return Command::FAILURE;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Enums;
/**
* CRM 病例标记位 ea_case_cstm.label_bit
*
* 对应 service 项目的 Eainc\Enum\Cases\LabelBitEnum。
*
* 数据流:
* 1. CRMservice)通过 /case/update/bit 写入 ea_case_cstm.label_bit
* 并投递 case_basic_info_change 事件(携带 labelBit);
* 2. agent-be CaseEventHandleService::fillCase 消费事件后写入
* cases.is_need_pfp = label_bit & DebtEnum::needMoney()
* 其中 needMoney() configs stuck_payment_reason 里所有 key 的按位或。
*
* 也就是说 is_need_pfp 并不是布尔值,而是「被 stuck_payment_reason 过滤后的卡款原因位图」。
*/
final class CaseLabelBit
{
/** @var int bit0 允许 APP 授信放行(非卡款原因) */
public const ALLOW_PROCESS_BY_HONEST = 1;
/** @var int bit1 新病例订单卡生产 */
public const APPLIANCE_NEED_MONEY = 2;
/** @var int bit2 产品变更(升档)卡生产 */
public const UPGRADE_NEED_MONEY = 4;
/** @var int bit3 病例延期产品卡生产 */
public const EXTENSION_NEED_MONEY = 8;
/** @var array<int,string> 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<int,string> 面向用户的原因解释 */
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)
));
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Config; use App\Models\Config;
use App\Services\ErpRequestReportService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -14,6 +15,7 @@ class ConfigController extends Controller
public function index(): JsonResponse public function index(): JsonResponse
{ {
$configs = Config::query() $configs = Config::query()
->where('key', '!=', ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->orderBy('key') ->orderBy('key')
->get(); ->get();
@@ -28,7 +30,7 @@ class ConfigController extends Controller
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$data = $request->validate([ $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'], 'value' => ['nullable', 'string'],
'description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:255'],
]); ]);
@@ -49,12 +51,15 @@ class ConfigController extends Controller
public function update(Request $request, Config $config): JsonResponse public function update(Request $request, Config $config): JsonResponse
{ {
$this->ensureNotProtected($config);
$data = $request->validate([ $data = $request->validate([
'key' => [ 'key' => [
'required', 'required',
'string', 'string',
'max:255', 'max:255',
Rule::unique('configs', 'key')->ignore($config->id), Rule::unique('configs', 'key')->ignore($config->id),
'not_in:'.ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY,
], ],
'value' => ['nullable', 'string'], 'value' => ['nullable', 'string'],
'description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:255'],
@@ -76,6 +81,8 @@ class ConfigController extends Controller
public function destroy(Config $config): JsonResponse public function destroy(Config $config): JsonResponse
{ {
$this->ensureNotProtected($config);
$config->delete(); $config->delete();
return response()->json([ return response()->json([
@@ -103,4 +110,13 @@ class ConfigController extends Controller
return $decoded; return $decoded;
} }
private function ensureNotProtected(Config $config): void
{
if ($config->key === ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY) {
throw ValidationException::withMessages([
'key' => '该配置只能通过 ERP 请求日报设置修改',
]);
}
}
} }
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\ConfigService;
use App\Services\ErpRequestReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ErpRequestReportConfigController extends Controller
{
public function __construct(private readonly ConfigService $configService) {}
public function show(): JsonResponse
{
return response()->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,
],
]);
}
}
@@ -49,11 +49,13 @@ class IpUserMappingController extends Controller
]); ]);
$mapping = IpUserMapping::query()->create($data); $mapping = IpUserMapping::query()->create($data);
$syncedCount = $this->syncMissingOperationLogUserLabels($mapping->ip_address, $mapping->user_name);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'mapping' => $mapping, 'mapping' => $mapping,
'synced_operation_logs_count' => $syncedCount,
], ],
]); ]);
} }
@@ -72,11 +74,14 @@ class IpUserMappingController extends Controller
]); ]);
$mapping->update($data); $mapping->update($data);
$mapping->refresh();
$syncedCount = $this->syncMissingOperationLogUserLabels($mapping->ip_address, $mapping->user_name);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'mapping' => $mapping->refresh(), 'mapping' => $mapping,
'synced_operation_logs_count' => $syncedCount,
], ],
]); ]);
} }
@@ -89,4 +94,15 @@ class IpUserMappingController extends Controller
'success' => true, 'success' => true,
]); ]);
} }
private function syncMissingOperationLogUserLabels(string $ipAddress, string $userName): int
{
return DB::table('operation_logs')
->where('ip_address', $ipAddress)
->where(function ($query): void {
$query->whereNull('user_label')
->orWhere('user_label', '');
})
->update(['user_label' => $userName]);
}
} }
@@ -0,0 +1,189 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Clients\JenkinsClient;
use App\Http\Controllers\Controller;
use App\Models\Project;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class JenkinsBuildController extends Controller
{
public function __construct(
private readonly JenkinsClient $jenkinsClient
) {}
public function projects(): JsonResponse
{
if (! $this->jenkinsClient->isConfigured()) {
return response()->json([
'success' => false,
'message' => 'Jenkins 未配置,请先配置 JENKINS_HOST、JENKINS_USERNAME、JENKINS_API_TOKEN',
], 422);
}
$projects = Project::getJenkinsNotifyEnabled()
->map(function (Project $project) {
return [
'id' => $project->id,
'slug' => $project->slug,
'name' => $project->name,
'jenkins_job_name' => $project->jenkins_job_name,
'parameters' => $this->jenkinsClient->getParameterDefinitions($project->jenkins_job_name),
];
})
->values();
return response()->json([
'success' => true,
'data' => [
'projects' => $projects,
],
]);
}
public function trigger(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.*.project_slug' => ['required', 'string', Rule::in($projectSlugs)],
'builds.*.parameters' => ['nullable', 'array'],
]);
$projects = Project::getJenkinsNotifyEnabled()->keyBy('slug');
$results = [];
$successCount = 0;
foreach ($data['builds'] as $build) {
/** @var Project $project */
$project = $projects[$build['project_slug']];
$parameters = $this->normalizeParameters($build['parameters'] ?? []);
$result = $this->jenkinsClient->triggerBuild($project->jenkins_job_name, $parameters);
if ($result['success'] ?? false) {
$successCount++;
}
$results[] = [
'project_slug' => $project->slug,
'project_name' => $project->name,
'job_name' => $project->jenkins_job_name,
'success' => (bool) ($result['success'] ?? false),
'message' => $result['message'] ?? null,
'queue_url' => $result['queue_url'] ?? null,
'build_number' => $result['build_number'] ?? null,
];
}
return response()->json([
'success' => $successCount === count($results),
'message' => sprintf('触发完成:成功 %d 个,失败 %d 个', $successCount, count($results) - $successCount),
'data' => [
'results' => $results,
],
], $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)
->filter(fn ($value) => $value !== null && $value !== '')
->map(function ($value) {
if (is_array($value)) {
return implode(',', array_filter($value, fn ($item) => $item !== null && $item !== ''));
}
return is_bool($value) ? ($value ? 'true' : 'false') : $value;
})
->filter(fn ($value) => $value !== '')
->all();
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\JenkinsDeployment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class JenkinsDeploymentController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = JenkinsDeployment::with('project:id,slug,name')
->orderByDesc('created_at');
if ($request->filled('project_id')) {
$query->where('project_id', $request->input('project_id'));
}
if ($request->filled('job_name')) {
$query->where('job_name', $request->input('job_name'));
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$perPage = min((int) $request->input('per_page', 20), 100);
$deployments = $query->paginate($perPage);
return response()->json($deployments);
}
public function show(int $id): JsonResponse
{
$deployment = JenkinsDeployment::with('project:id,slug,name')->findOrFail($id);
return response()->json($deployment);
}
}
@@ -82,6 +82,8 @@ class ProjectController extends Controller
'log_app_names' => ['nullable', 'array'], 'log_app_names' => ['nullable', 'array'],
'log_app_names.*' => ['string', 'max:100'], 'log_app_names.*' => ['string', 'max:100'],
'log_env' => ['nullable', 'string', 'max:50'], 'log_env' => ['nullable', 'string', 'max:50'],
'jenkins_job_name' => ['nullable', 'string', 'max:255'],
'jenkins_notify_enabled' => ['nullable', 'boolean'],
]); ]);
$project = $this->projectService->create($data); $project = $this->projectService->create($data);
@@ -126,6 +128,8 @@ class ProjectController extends Controller
'log_app_names' => ['nullable', 'array'], 'log_app_names' => ['nullable', 'array'],
'log_app_names.*' => ['string', 'max:100'], 'log_app_names.*' => ['string', 'max:100'],
'log_env' => ['nullable', 'string', 'max:50'], 'log_env' => ['nullable', 'string', 'max:50'],
'jenkins_job_name' => ['nullable', 'string', 'max:255'],
'jenkins_notify_enabled' => ['nullable', 'boolean'],
]); ]);
$project = $this->projectService->update($project, $data); $project = $this->projectService->update($project, $data);
+39 -23
View File
@@ -4,11 +4,13 @@ namespace App\Http\Controllers;
use App\Services\JiraService; use App\Services\JiraService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class JiraController extends Controller class JiraController extends Controller
{ {
private const WEEKLY_REPORT_PERIODS = ['this_week', 'last_week'];
private JiraService $jiraService; private JiraService $jiraService;
public function __construct(JiraService $jiraService) public function __construct(JiraService $jiraService)
@@ -16,37 +18,44 @@ class JiraController extends Controller
$this->jiraService = $jiraService; $this->jiraService = $jiraService;
} }
/** /**
* 生成上周周报 * 生成周报
*/ */
public function generateWeeklyReport(Request $request): JsonResponse public function generateWeeklyReport(Request $request): JsonResponse
{ {
try { try {
$username = $request->input('username') ?: config('jira.default_user'); $username = $request->input('username') ?: config('jira.default_user');
$period = $request->input('period', 'this_week');
if (!$username) { if (! $username) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '请提供用户名' 'message' => '请提供用户名',
], 400); ], 400);
} }
$report = $this->jiraService->generateWeeklyReport($username); if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) {
return response()->json([
'success' => false,
'message' => '无效的周报周期',
], 400);
}
$report = $this->jiraService->generateWeeklyReport($username, $period);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'report' => $report, 'report' => $report,
'username' => $username, 'username' => $username,
'generated_at' => Carbon::now()->format('Y-m-d H:i:s') 'period' => $period,
] 'generated_at' => Carbon::now()->format('Y-m-d H:i:s'),
],
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '生成周报失败: ' . $e->getMessage() 'message' => '生成周报失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
@@ -77,14 +86,14 @@ class JiraController extends Controller
'total_records' => $workLogs->count(), 'total_records' => $workLogs->count(),
'date_range' => [ 'date_range' => [
'start' => $startDate->format('Y-m-d'), 'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d') 'end' => $endDate->format('Y-m-d'),
] ],
] ],
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '获取工时记录失败: ' . $e->getMessage() 'message' => '获取工时记录失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
@@ -98,8 +107,8 @@ class JiraController extends Controller
'success' => true, 'success' => true,
'data' => [ 'data' => [
'default_user' => config('jira.default_user', ''), 'default_user' => config('jira.default_user', ''),
'host' => config('jira.host', '') 'host' => config('jira.host', ''),
] ],
]); ]);
} }
@@ -110,26 +119,33 @@ class JiraController extends Controller
{ {
try { try {
$username = $request->input('username') ?: config('jira.default_user'); $username = $request->input('username') ?: config('jira.default_user');
$period = $request->input('period', 'this_week');
if (!$username) { if (! $username) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '请提供用户名' 'message' => '请提供用户名',
], 400); ], 400);
} }
$report = $this->jiraService->generateWeeklyReport($username); if (! in_array($period, self::WEEKLY_REPORT_PERIODS, true)) {
$filename = sprintf('weekly_report_%s_%s.md', $username, Carbon::now()->subWeek()->format('Y-m-d')); return response()->json([
'success' => false,
'message' => '无效的周报周期',
], 400);
}
$report = $this->jiraService->generateWeeklyReport($username, $period);
$filename = sprintf('weekly_report_%s_%s_%s.md', $username, $period, Carbon::now()->format('Y-m-d'));
return response($report) return response($report)
->header('Content-Type', 'text/markdown') ->header('Content-Type', 'text/markdown')
->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); ->header('Content-Disposition', 'attachment; filename="'.$filename.'"');
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '下载周报失败: ' . $e->getMessage() 'message' => '下载周报失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
} }
@@ -0,0 +1,51 @@
<?php
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 diagnose(Request $request): JsonResponse
{
try {
$validated = $request->validate([
'type' => 'required|in:case,business_document,sale_document',
'code' => 'required|string|max:64',
]);
$result = $this->service->diagnose($validated['type'], trim($validated['code']));
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'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' => '诊断服务暂不可用,请稍后重试',
], 500);
}
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProductionDiagnosisPageController extends Controller
{
public function __invoke(Request $request): View
{
if (strtolower($request->getHost()) === config('toolbox.admin_host')) {
return view('admin.index');
}
return view('production-diagnosis.index');
}
}
+206 -2
View File
@@ -25,7 +25,7 @@ class SqlGeneratorController extends Controller
if (empty($caseCodes)) { if (empty($caseCodes)) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '请提供有效的 case_id 列表' 'message' => '请提供有效的 case_id 列表',
], 400); ], 400);
} }
@@ -59,8 +59,212 @@ class SqlGeneratorController extends Controller
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '查询 case_extras 失败: ' . $e->getMessage(), 'message' => '查询 case_extras 失败: '.$e->getMessage(),
], 500); ], 500);
} }
} }
/**
* 查询 CRM 加工单关联地址国家,用于区分 PP-CN / PP-US。
*/
public function checkProductionCountries(Request $request): JsonResponse
{
try {
$request->validate([
'production_codes' => 'required|array|min:1',
'production_codes.*' => 'required|string|max:255',
]);
$productionCodes = array_values(array_unique(array_filter(array_map('trim', $request->input('production_codes')))));
if (empty($productionCodes)) {
return response()->json([
'success' => false,
'message' => '请提供有效的加工单列表',
], 400);
}
$productionCountries = $this->getProductionCountries($productionCodes);
return response()->json([
'success' => true,
'data' => [
'production_countries' => $productionCountries,
],
]);
} catch (ValidationException $e) {
return response()->json([
'success' => false,
'message' => '请求参数验证失败',
'errors' => $e->errors(),
], 422);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '查询 CRM 加工单国家失败: '.$e->getMessage(),
], 500);
}
}
private function getProductionCountries(array $productionCodes): array
{
$productionCountries = [];
foreach (array_chunk($productionCodes, 1000) as $chunk) {
$productions = DB::connection('crmslave')
->table('ea_production as ep')
->join('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c')
->where('ep.deleted', 0)
->whereIn('ep.name', $chunk)
->select([
'ep.name as production_code',
'epc.ea_case_id_c',
'epc.ea_businessorder_id_c',
'epc.ea_salesorder_id_c',
])
->get();
$caseIds = $productions->pluck('ea_case_id_c')->filter()->unique()->values()->all();
$businessOrderIds = $productions->pluck('ea_businessorder_id_c')->filter()->unique()->values()->all();
$salesOrderIds = $productions->pluck('ea_salesorder_id_c')->filter()->unique()->values()->all();
$caseCountries = $this->getCountriesByCaseIds($caseIds);
$businessOrderCountries = $this->getCountriesByBusinessOrderIds($businessOrderIds);
$salesOrderCountries = $this->getCountriesBySalesOrderIds($salesOrderIds);
foreach ($productions as $production) {
$countries = array_merge(
$caseCountries[(string) $production->ea_case_id_c] ?? [],
$businessOrderCountries[(string) $production->ea_businessorder_id_c] ?? [],
$salesOrderCountries[(string) $production->ea_salesorder_id_c] ?? []
);
$productionCountries[(string) $production->production_code] = array_values(array_unique(array_filter($countries)));
}
}
return $productionCountries;
}
private function getCountriesByCaseIds(array $caseIds): array
{
if (empty($caseIds)) {
return [];
}
$results = DB::connection('crmslave')
->table('ea_case as ec')
->join('accounts_ea_case_1_c as aec1c', function ($join) {
$join->on('aec1c.accounts_ea_case_1ea_case_idb', '=', 'ec.id')
->where('aec1c.deleted', '=', 0);
})
->join('accounts as a', 'a.id', '=', 'aec1c.accounts_ea_case_1accounts_ida')
->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id')
->where('ec.deleted', 0)
->where('a.deleted', 0)
->whereIn('ec.id', $caseIds)
->whereNotNull('ac.country_c')
->select([
'ec.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->get();
return $this->groupCountriesByEntityId($results);
}
private function getCountriesByBusinessOrderIds(array $businessOrderIds): array
{
if (empty($businessOrderIds)) {
return [];
}
$results = DB::connection('crmslave')
->table('ea_businessorder as eb')
->join('accounts_ea_businessorder_1_c as aeb1c', function ($join) {
$join->on('aeb1c.accounts_ea_businessorder_1ea_businessorder_idb', '=', 'eb.id')
->where('aeb1c.deleted', '=', 0);
})
->join('accounts as a', 'a.id', '=', 'aeb1c.accounts_ea_businessorder_1accounts_ida')
->join('accounts_cstm as ac', 'ac.id_c', '=', 'a.id')
->where('eb.deleted', 0)
->where('a.deleted', 0)
->whereIn('eb.id', $businessOrderIds)
->whereNotNull('ac.country_c')
->select([
'eb.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->get();
return $this->groupCountriesByEntityId($results);
}
private function getCountriesBySalesOrderIds(array $salesOrderIds): array
{
if (empty($salesOrderIds)) {
return [];
}
$results = DB::connection('crmslave')
->table('ea_salesorder as es')
->join('accounts_ea_salesorder_1_c as aes1c', function ($join) {
$join->on('aes1c.accounts_ea_salesorder_1ea_salesorder_idb', '=', 'es.id')
->where('aes1c.deleted', '=', 0);
})
->join('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->join('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->where('es.deleted', 0)
->where('a_base.deleted', 0)
->whereIn('es.id', $salesOrderIds)
->whereNotNull('ac.country_c')
->select([
'es.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->get();
return $this->groupCountriesByEntityId($results);
}
private function groupCountriesByEntityId($results): array
{
$countriesByEntityId = [];
foreach ($results as $result) {
$countryCode = $this->getCountryCode($result->country, $result->province);
if (! $countryCode) {
continue;
}
$entityId = (string) $result->entity_id;
$countriesByEntityId[$entityId] ??= [];
$countriesByEntityId[$entityId][] = $countryCode;
}
return array_map(fn ($countries) => array_values(array_unique($countries)), $countriesByEntityId);
}
private function getCountryCode(?string $country, ?string $province): ?string
{
if (! $country) {
return null;
}
if (in_array($country, ['1', '156'], true) && ! in_array((string) $province, ['710000', '810000', '820000'], true)) {
return 'CN';
}
return [
'840' => 'US',
'US' => 'US',
'316' => 'GU',
'GU' => 'GU',
'630' => 'PR',
'PR' => 'PR',
][strtoupper($country)] ?? strtoupper($country);
}
} }
+460
View File
@@ -0,0 +1,460 @@
<?php
namespace App\Http\Controllers;
use App\Services\AiService;
use App\Services\JiraService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TestMailController extends Controller
{
public function __construct(
private JiraService $jiraService,
private AiService $aiService
)
{
}
public function sprints(): JsonResponse
{
return response()->json([
'success' => true,
'data' => [
'sprints' => $this->jiraService->getTestMailSprintOptions()->all(),
'defaults' => $this->jiraService->getTestMailTemplateDefaults(),
],
]);
}
public function data(Request $request): JsonResponse
{
$request->validate([
'sprint' => 'required|string|max:50',
]);
$sprint = trim((string) $request->query('sprint'));
$issues = $this->jiraService->getSprintTestIssues($sprint);
$period = $this->jiraService->resolveTestMailSprintPeriod($sprint, $issues);
return response()->json([
'success' => true,
'data' => [
'sprint' => $sprint,
'sprint_period' => $period,
'suggested_subject' => $this->buildTestMailSubject($period ?: 'Sprint'.$sprint),
'jql' => sprintf('project in (WP, AM, TP) AND issuetype = Story AND status in (开发中, 测试中, 需求调研中, 需求已调研, 需求已评审, 已提测, 待上线, 需求已排期, 待提测, 产品验收) AND Sprint = %s ORDER BY priority DESC, cf[10004] ASC, key ASC', $sprint),
'issues' => $issues->values()->all(),
'defaults' => $this->jiraService->getTestMailTemplateDefaults(),
'sprints' => $this->jiraService->getTestMailSprintOptions()->all(),
],
]);
}
public function draftSections(Request $request): JsonResponse
{
$validated = $request->validate([
'issues' => 'array',
'issues.*.key' => 'nullable|string|max:50',
'issues.*.summary' => 'nullable|string|max:500',
'issues.*.project_key' => 'nullable|string|max:50',
'issues.*.project_name' => 'nullable|string|max:120',
'issues.*.developer' => 'nullable|string|max:120',
'issues.*.assignee' => 'nullable|string|max:120',
'issues.*.requirement_type' => 'nullable|string|max:120',
'tech_docs' => 'nullable|string|max:2000',
'selected_containers' => 'array',
'selected_containers.*' => 'string|max:120',
'databases' => 'array',
'databases.*.system' => 'nullable|string|max:50',
'databases.*.has_database' => 'nullable|string|max:50',
'databases.*.branch' => 'nullable|string|max:160',
]);
$fallback = $this->buildRuleBasedDraftSections($validated);
$source = 'rules';
if ($this->aiService->isConfigured()) {
try {
$aiDraft = $this->buildAiDraftSections($validated);
if (! empty($aiDraft['test_notes']) || ! empty($aiDraft['risks'])) {
$fallback = array_replace($fallback, array_filter($aiDraft));
$source = 'ai';
}
} catch (\Throwable) {
$source = 'rules';
}
}
return response()->json([
'success' => true,
'data' => [
'test_notes' => $fallback['test_notes'],
'risks' => $fallback['risks'],
'source' => $source,
],
]);
}
public function databases(Request $request): JsonResponse
{
$validated = $request->validate([
'selected_groups' => 'array',
'selected_groups.*' => 'string',
'versions' => 'array',
]);
return response()->json([
'success' => true,
'data' => [
'databases' => $this->jiraService->buildTestMailDatabases(
$validated['selected_groups'] ?? [],
$validated['versions'] ?? []
),
],
]);
}
public function download(Request $request)
{
$validated = $request->validate([
'subject' => 'required|string|max:255',
'from' => 'nullable|string|max:255',
'to' => 'nullable|string',
'cc' => 'nullable|string',
'html' => 'required|string',
'text' => 'nullable|string',
'images' => 'array',
'images.*.cid' => 'required|string|max:180',
'images.*.name' => 'nullable|string|max:180',
'images.*.dataUrl' => 'required|string',
]);
$subject = $validated['subject'];
$eml = $this->buildEml(
$subject,
$validated['html'],
$validated['text'] ?? $this->htmlToText($validated['html']),
$validated['images'] ?? [],
$validated['from'] ?: '万文山 <wanwenshan@angelalign.com>',
$validated['to'] ?? '',
$validated['cc'] ?? ''
);
$filename = Str::slug(str_replace(['【', '】'], '', $subject), '_');
if ($filename === '') {
$filename = 'test_mail';
}
return response($eml)
->header('Content-Type', 'message/rfc822; charset=UTF-8')
->header('Content-Disposition', 'attachment; filename="'.$filename.'.eml"');
}
public function openDraft(Request $request): JsonResponse
{
$validated = $request->validate([
'subject' => 'required|string|max:255',
'from' => 'nullable|string|max:255',
'to' => 'required|string',
'cc' => 'nullable|string',
'html' => 'required|string',
'text' => 'required|string',
'images' => 'array',
'images.*.cid' => 'required|string|max:180',
'images.*.name' => 'nullable|string|max:180',
'images.*.dataUrl' => 'required|string',
]);
$thunderbird = trim((string) shell_exec('command -v thunderbird 2>/dev/null'));
if ($thunderbird === '') {
return response()->json([
'success' => false,
'message' => '未找到 thunderbird 命令',
], 422);
}
$draftDir = storage_path('app/test-mail-drafts');
if (! is_dir($draftDir)) {
mkdir($draftDir, 0775, true);
}
$html = $this->inlineDraftImages($validated['html'], $validated['images'] ?? []);
$filename = (Str::slug(str_replace(['【', '】'], '', $validated['subject']), '_') ?: 'test_mail').'_'.date('Ymd_His').'.html';
$draftPath = $draftDir.DIRECTORY_SEPARATOR.$filename;
file_put_contents($draftPath, $html);
$compose = implode(',', array_filter([
! empty($validated['from']) ? 'from='.$this->thunderbirdComposeValue($validated['from']) : null,
'to='.$this->thunderbirdComposeValue($validated['to']),
! empty($validated['cc']) ? 'cc='.$this->thunderbirdComposeValue($validated['cc']) : null,
'subject='.$this->thunderbirdComposeValue($validated['subject']),
'message='.$this->thunderbirdComposeValue($draftPath),
'format=html',
]));
$uid = function_exists('posix_getuid') ? (string) posix_getuid() : trim((string) shell_exec('id -u 2>/dev/null'));
$xdgRuntimeDir = getenv('XDG_RUNTIME_DIR') ?: ($uid !== '' ? '/run/user/'.$uid : '');
$env = array_filter([
'DISPLAY='.(getenv('DISPLAY') ?: ':0'),
'WAYLAND_DISPLAY='.(getenv('WAYLAND_DISPLAY') ?: 'wayland-0'),
$xdgRuntimeDir !== '' ? 'XDG_RUNTIME_DIR='.$xdgRuntimeDir : null,
'DBUS_SESSION_BUS_ADDRESS='.(getenv('DBUS_SESSION_BUS_ADDRESS') ?: ($xdgRuntimeDir !== '' ? 'unix:path='.$xdgRuntimeDir.'/bus' : '')),
]);
$command = sprintf(
'timeout 10s env %s %s -compose %s 2>&1',
implode(' ', array_map('escapeshellarg', $env)),
escapeshellarg($thunderbird),
escapeshellarg($compose)
);
exec($command, $output, $code);
return response()->json([
'success' => $code === 0,
'message' => $code === 0 ? '已向 Thunderbird 发送打开 HTML 邮件草稿请求' : ('打开 Thunderbird 失败:'.trim(implode("\n", $output))),
], $code === 0 ? 200 : 500);
}
private function buildTestMailSubject(string $period): string
{
return sprintf('【提测】%s需求提测(SP、PP、TP)', $period);
}
private function buildRuleBasedDraftSections(array $context): array
{
$issues = collect($context['issues'] ?? []);
$containers = collect($context['selected_containers'] ?? [])->filter()->values();
$databases = collect($context['databases'] ?? [])->filter(fn ($row) => ($row['has_database'] ?? '') === '有')->values();
$owners = $issues->pluck('developer')->merge($issues->pluck('assignee'))->filter()->unique()->values();
$testNotes = [];
if (trim((string) ($context['tech_docs'] ?? '')) !== '' && trim((string) ($context['tech_docs'] ?? '')) !== '无') {
$testNotes[] = [
'type' => '测试注意事项',
'issue' => $this->summarizeIssueKeys($issues),
'system' => 'SP/PP/TP',
'content' => '测试前请先阅读技术文档,重点关注接口、配置和兼容性说明。',
'owner' => $owners->first() ?? '',
];
}
if ($containers->isNotEmpty()) {
$testNotes[] = [
'type' => '其他依赖项',
'issue' => $this->summarizeIssueKeys($issues),
'system' => $containers->take(4)->implode(', '),
'content' => '请确认本次涉及容器已部署对应版本,部署完成后再开始主流程验证。',
'owner' => $owners->first() ?? '',
];
}
foreach ($databases as $database) {
$testNotes[] = [
'type' => '脚本',
'issue' => $this->summarizeIssueKeys($issues),
'system' => $database['system'] ?? '',
'content' => '请确认数据库脚本分支已合入并执行:'.($database['branch'] ?? ''),
'owner' => $owners->first() ?? '',
];
}
if ($testNotes === []) {
$testNotes[] = [
'type' => '测试注意事项',
'issue' => $this->summarizeIssueKeys($issues),
'system' => 'SP/PP/TP',
'content' => '按本次提测需求逐项回归,重点覆盖新增流程、状态流转和权限边界。',
'owner' => $owners->first() ?? '',
];
}
$risks = [[
'problem' => $databases->isNotEmpty() ? '涉及数据库变更,需确认脚本执行顺序和环境一致性。' : '暂无明确已知问题,测试过程中如发现阻塞需及时同步。',
'impact' => $databases->isNotEmpty() ? '脚本遗漏或顺序错误可能影响相关需求验证。' : '可能影响本次提测范围内需求验收进度。',
'action' => $databases->isNotEmpty() ? '部署前由研发确认脚本分支,测试开始前完成环境冒烟。' : '按需求清单跟踪问题,阻塞项及时拉群确认处理方案。',
'owner' => $owners->first() ?? '',
]];
return [
'test_notes' => array_slice($testNotes, 0, 5),
'risks' => $risks,
];
}
private function buildAiDraftSections(array $context): array
{
$content = json_encode([
'issues' => collect($context['issues'] ?? [])->take(20)->values(),
'tech_docs' => $context['tech_docs'] ?? '',
'selected_containers' => $context['selected_containers'] ?? [],
'databases' => $context['databases'] ?? [],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$prompt = <<<'PROMPT'
请根据提测邮件上下文生成第九和第十部分的简略表格草稿。
只输出 JSON,不要 Markdown,不要解释。格式:
{
"test_notes": [{"type":"脚本|配置项|其他依赖项|测试注意事项","issue":"需求key或事项","system":"系统/容器","content":"简短说明","owner":"负责人"}],
"risks": [{"problem":"已知问题/风险","impact":"影响范围","action":"处理方案/规避措施","owner":"负责人"}]
}
要求:最多 5 test_notes,最多 3 risks;内容要短,适合直接放入提测邮件;没有明确风险时给一条“暂无明确已知问题”的保守项。
PROMPT;
$response = $this->aiService->analyze($content ?: '{}', $prompt);
$json = $this->extractJsonObject($response);
if ($json === null) {
return [];
}
$decoded = json_decode($json, true);
if (! is_array($decoded)) {
return [];
}
return [
'test_notes' => $this->normalizeRows($decoded['test_notes'] ?? [], ['type', 'issue', 'system', 'content', 'owner']),
'risks' => $this->normalizeRows($decoded['risks'] ?? [], ['problem', 'impact', 'action', 'owner']),
];
}
private function normalizeRows(array $rows, array $keys): array
{
return collect($rows)->filter(fn ($row) => is_array($row))->map(function ($row) use ($keys) {
$normalized = [];
foreach ($keys as $key) {
$normalized[$key] = trim((string) ($row[$key] ?? ''));
}
return $normalized;
})->filter(fn ($row) => collect($row)->filter()->isNotEmpty())->values()->all();
}
private function extractJsonObject(string $response): ?string
{
$response = trim($response);
if ($response === '') {
return null;
}
if (str_starts_with($response, '```')) {
$response = preg_replace('/^```(?:json)?\s*|\s*```$/u', '', $response);
}
if (preg_match('/\{.*\}/su', $response, $matches)) {
return $matches[0];
}
return null;
}
private function summarizeIssueKeys($issues): string
{
$keys = collect($issues)->pluck('key')->filter()->take(6)->implode(', ');
return $keys ?: '本次提测需求';
}
private function buildEml(string $subject, string $html, string $text, array $images, string $from, string $to, string $cc): string
{
$mixedBoundary = '----=_Part_'.bin2hex(random_bytes(12));
$relatedBoundary = '----=_Related_'.bin2hex(random_bytes(12));
$alternativeBoundary = '----=_Alternative_'.bin2hex(random_bytes(12));
$headers = [
'From: '.$from,
'To: '.$to,
'Cc: '.$cc,
'Subject: =?UTF-8?B?'.base64_encode($subject).'?=',
'Date: '.date(DATE_RFC2822),
'MIME-Version: 1.0',
'Content-Type: multipart/mixed; boundary="'.$mixedBoundary.'"',
];
$body = [];
$body[] = '--'.$mixedBoundary;
$body[] = 'Content-Type: multipart/alternative; boundary="'.$alternativeBoundary.'"';
$body[] = '';
$body[] = '--'.$alternativeBoundary;
$body[] = 'Content-Type: text/plain; charset=UTF-8';
$body[] = 'Content-Transfer-Encoding: base64';
$body[] = '';
$body[] = chunk_split(base64_encode($text));
$body[] = '--'.$alternativeBoundary;
$body[] = 'Content-Type: multipart/related; boundary="'.$relatedBoundary.'"';
$body[] = '';
$body[] = '--'.$relatedBoundary;
$body[] = 'Content-Type: text/html; charset=UTF-8';
$body[] = 'Content-Transfer-Encoding: base64';
$body[] = '';
$body[] = chunk_split(base64_encode($html));
foreach ($images as $index => $image) {
$parsed = $this->parseDataUrl($image['dataUrl']);
if (! $parsed) {
continue;
}
$cid = trim($image['cid'], '<>');
$name = $image['name'] ?? ('screenshot-'.($index + 1).'.png');
$body[] = '--'.$relatedBoundary;
$body[] = 'Content-Type: '.$parsed['mime'].'; name="'.$this->escapeHeader($name).'"';
$body[] = 'Content-Transfer-Encoding: base64';
$body[] = 'Content-ID: <'.$cid.'>';
$body[] = 'Content-Disposition: inline; filename="'.$this->escapeHeader($name).'"';
$body[] = '';
$body[] = chunk_split(base64_encode($parsed['bytes']));
}
$body[] = '--'.$relatedBoundary.'--';
$body[] = '';
$body[] = '--'.$alternativeBoundary.'--';
$body[] = '';
$body[] = '--'.$mixedBoundary.'--';
$body[] = '';
return implode("\r\n", $headers)."\r\n\r\n".implode("\r\n", $body);
}
private function parseDataUrl(string $dataUrl): ?array
{
if (! preg_match('/^data:([^;]+);base64,(.*)$/s', $dataUrl, $matches)) {
return null;
}
$bytes = base64_decode($matches[2], true);
if ($bytes === false) {
return null;
}
return ['mime' => $matches[1], 'bytes' => $bytes];
}
private function htmlToText(string $html): string
{
return trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
private function escapeHeader(string $value): string
{
return str_replace(['"', "\r", "\n"], ['\\"', '', ''], $value);
}
private function inlineDraftImages(string $html, array $images): string
{
foreach ($images as $image) {
$cid = trim((string) ($image['cid'] ?? ''), '<>');
$dataUrl = (string) ($image['dataUrl'] ?? '');
if ($cid === '' || $dataUrl === '') {
continue;
}
$html = str_replace('cid:'.$cid, $dataUrl, $html);
}
return $html;
}
private function thunderbirdComposeValue(string $value): string
{
return "'".str_replace(
["\\", "'"],
["\\\\", "\\'"],
str_replace("\r\n", "\n", $value)
)."'";
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HostAccessMiddleware
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$host = strtolower(trim($request->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'));
}
}
+1 -2
View File
@@ -101,8 +101,7 @@ class LogAnalysisJob implements ShouldQueue
if (in_array($impact, ['high', 'medium'])) { if (in_array($impact, ['high', 'medium'])) {
$codeAnalysisResult = $codeAnalysisService->analyze( $codeAnalysisResult = $codeAnalysisService->analyze(
$appName, $appName,
$logsContent, $results[$appName]
$results[$appName]['summary'] ?? null
); );
$results[$appName]['code_analysis'] = $codeAnalysisResult; $results[$appName]['code_analysis'] = $codeAnalysisResult;
} }
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class JenkinsDeployment extends BaseModel
{
protected $fillable = [
'project_id',
'build_number',
'job_name',
'status',
'branch',
'commit_sha',
'triggered_by',
'duration',
'build_url',
'raw_data',
'build_params',
'notified',
];
protected $casts = [
'raw_data' => 'array',
'build_params' => 'array',
'notified' => 'boolean',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function getFormattedDuration(): string
{
if (!$this->duration) {
return '-';
}
$seconds = (int) ($this->duration / 1000);
$minutes = (int) ($seconds / 60);
$seconds = $seconds % 60;
return sprintf('%02d:%02d', $minutes, $seconds);
}
public function getStatusEmoji(): string
{
return match ($this->status) {
'SUCCESS' => '✅',
'FAILURE' => '❌',
'ABORTED' => '⏹️',
'UNSTABLE' => '⚠️',
default => '❓',
};
}
public function getStatusLabel(): string
{
return match ($this->status) {
'SUCCESS' => '成功',
'FAILURE' => '失败',
'ABORTED' => '已中止',
'UNSTABLE' => '不稳定',
default => '未知',
};
}
}
+15
View File
@@ -20,12 +20,16 @@ class Project extends BaseModel
'git_version_cached_at', 'git_version_cached_at',
'log_app_names', 'log_app_names',
'log_env', 'log_env',
'jenkins_job_name',
'jenkins_notify_enabled',
'jenkins_last_notified_build',
]; ];
protected $casts = [ protected $casts = [
'git_monitor_enabled' => 'boolean', 'git_monitor_enabled' => 'boolean',
'auto_create_release_branch' => 'boolean', 'auto_create_release_branch' => 'boolean',
'is_important' => 'boolean', 'is_important' => 'boolean',
'jenkins_notify_enabled' => 'boolean',
'log_app_names' => 'array', 'log_app_names' => 'array',
'git_version_cached_at' => 'datetime', 'git_version_cached_at' => 'datetime',
]; ];
@@ -86,4 +90,15 @@ class Project extends BaseModel
->whereJsonContains('log_app_names', $appName) ->whereJsonContains('log_app_names', $appName)
->first(); ->first();
} }
/**
* 获取所有启用 Jenkins 通知的项目
*/
public static function getJenkinsNotifyEnabled(): \Illuminate\Database\Eloquent\Collection
{
return static::query()
->where('jenkins_notify_enabled', true)
->whereNotNull('jenkins_job_name')
->get();
}
} }
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ScheduledTask extends Model
{
protected $fillable = [
'name',
'command',
'description',
'frequency',
'cron',
'enabled',
];
protected $casts = [
'enabled' => 'boolean',
];
}
+6
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Clients\AgentClient; use App\Clients\AgentClient;
use App\Clients\AiClient; use App\Clients\AiClient;
use App\Clients\CrmClient;
use App\Clients\MonoClient; use App\Clients\MonoClient;
use App\Clients\SlsClient; use App\Clients\SlsClient;
use App\Services\AiService; use App\Services\AiService;
@@ -11,9 +12,11 @@ use App\Services\CodeContextService;
use App\Services\ConfigService; use App\Services\ConfigService;
use App\Services\DingTalkService; use App\Services\DingTalkService;
use App\Services\EnvService; use App\Services\EnvService;
use App\Services\ErpRequestReportService;
use App\Services\GitMonitorService; use App\Services\GitMonitorService;
use App\Services\JiraService; use App\Services\JiraService;
use App\Services\LogAnalysisService; use App\Services\LogAnalysisService;
use App\Services\ProductionDiagnosisService;
use App\Services\SlsService; use App\Services\SlsService;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -28,6 +31,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(AgentClient::class); $this->app->singleton(AgentClient::class);
$this->app->singleton(MonoClient::class); $this->app->singleton(MonoClient::class);
$this->app->singleton(SlsClient::class); $this->app->singleton(SlsClient::class);
$this->app->singleton(CrmClient::class);
$this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class))); $this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class)));
// 注册 Services // 注册 Services
@@ -35,11 +39,13 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(JiraService::class); $this->app->singleton(JiraService::class);
$this->app->singleton(DingTalkService::class); $this->app->singleton(DingTalkService::class);
$this->app->singleton(EnvService::class); $this->app->singleton(EnvService::class);
$this->app->singleton(ErpRequestReportService::class);
$this->app->singleton(GitMonitorService::class); $this->app->singleton(GitMonitorService::class);
$this->app->singleton(SlsService::class); $this->app->singleton(SlsService::class);
$this->app->singleton(AiService::class); $this->app->singleton(AiService::class);
$this->app->singleton(CodeContextService::class); $this->app->singleton(CodeContextService::class);
$this->app->singleton(LogAnalysisService::class); $this->app->singleton(LogAnalysisService::class);
$this->app->singleton(ProductionDiagnosisService::class);
} }
/** /**
+176 -21
View File
@@ -8,6 +8,7 @@ use Symfony\Component\Process\Process;
class CodeAnalysisService class CodeAnalysisService
{ {
public const TOOL_GEMINI = 'gemini';
public const TOOL_CLAUDE = 'claude'; public const TOOL_CLAUDE = 'claude';
public const TOOL_CODEX = 'codex'; public const TOOL_CODEX = 'codex';
@@ -22,11 +23,10 @@ class CodeAnalysisService
* 使用配置的工具在项目中分析日志问题 * 使用配置的工具在项目中分析日志问题
* *
* @param string $appName 应用名称 * @param string $appName 应用名称
* @param string $logsContent 日志内容 * @param array $aiAnalysisResult AI 分析结果(包含 summary, impact, core_anomalies 等)
* @param string|null $aiSummary AI 初步分析摘要
* @return array 分析结果 * @return array 分析结果
*/ */
public function analyze(string $appName, string $logsContent, ?string $aiSummary): array public function analyze(string $appName, array $aiAnalysisResult): array
{ {
$repoPath = $this->codeContextService->getRepoPath($appName); $repoPath = $this->codeContextService->getRepoPath($appName);
@@ -40,7 +40,7 @@ class CodeAnalysisService
$tool = $this->getConfiguredTool(); $tool = $this->getConfiguredTool();
try { try {
$prompt = $this->buildPrompt($logsContent, $aiSummary); $prompt = $this->buildPrompt($aiAnalysisResult);
$output = $this->runTool($tool, $repoPath, $prompt); $output = $this->runTool($tool, $repoPath, $prompt);
return [ return [
@@ -83,10 +83,10 @@ class CodeAnalysisService
*/ */
public function getConfiguredTool(): string public function getConfiguredTool(): string
{ {
$tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_CLAUDE); $tool = $this->configService->get('log_analysis.code_analysis_tool', self::TOOL_GEMINI);
if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) { if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) {
return self::TOOL_CLAUDE; return self::TOOL_GEMINI;
} }
return $tool; return $tool;
@@ -97,14 +97,14 @@ class CodeAnalysisService
*/ */
public function setTool(string $tool): void public function setTool(string $tool): void
{ {
if (!in_array($tool, [self::TOOL_CLAUDE, self::TOOL_CODEX])) { if (!in_array($tool, [self::TOOL_GEMINI, self::TOOL_CLAUDE, self::TOOL_CODEX])) {
throw new \InvalidArgumentException("不支持的工具: {$tool}"); throw new \InvalidArgumentException("不支持的工具: {$tool}");
} }
$this->configService->set( $this->configService->set(
'log_analysis.code_analysis_tool', 'log_analysis.code_analysis_tool',
$tool, $tool,
'代码分析工具 (claude/codex)' '代码分析工具 (gemini/claude/codex)'
); );
} }
@@ -114,6 +114,10 @@ class CodeAnalysisService
public function getAvailableTools(): array public function getAvailableTools(): array
{ {
return [ return [
self::TOOL_GEMINI => [
'name' => 'Gemini CLI',
'description' => 'Google Gemini 命令行工具',
],
self::TOOL_CLAUDE => [ self::TOOL_CLAUDE => [
'name' => 'Claude CLI', 'name' => 'Claude CLI',
'description' => 'Anthropic Claude 命令行工具', 'description' => 'Anthropic Claude 命令行工具',
@@ -126,21 +130,52 @@ class CodeAnalysisService
} }
/** /**
* 构建提示词 * 构建提示词(基于 AI 分析汇总结果)
*/ */
private function buildPrompt(string $logsContent, ?string $aiSummary): string private function buildPrompt(array $aiAnalysisResult): string
{ {
$prompt = "分析以下错误日志,在代码库中排查根本原因并给出具体优化方案:\n\n"; $prompt = "根据以下日志分析结果,在代码库中排查根本原因并给出具体优化方案:\n\n";
$prompt .= "=== 日志内容 ===\n{$logsContent}\n\n";
if ($aiSummary) { // 影响级别
$prompt .= "=== AI 初步分析 ===\n{$aiSummary}\n\n"; $impact = $aiAnalysisResult['impact'] ?? 'unknown';
$prompt .= "=== 影响级别 ===\n{$impact}\n\n";
// AI 摘要
$summary = $aiAnalysisResult['summary'] ?? '';
if ($summary) {
$prompt .= "=== 问题摘要 ===\n{$summary}\n\n";
}
// 核心异常列表
$anomalies = $aiAnalysisResult['core_anomalies'] ?? [];
if (!empty($anomalies)) {
$prompt .= "=== 异常列表 ===\n";
foreach ($anomalies as $idx => $anomaly) {
$num = $idx + 1;
$type = $anomaly['type'] ?? 'unknown';
$classification = $anomaly['classification'] ?? '';
$count = $anomaly['count'] ?? 1;
$cause = $anomaly['possible_cause'] ?? '';
$sample = $anomaly['sample'] ?? '';
$prompt .= "{$num}. [{$type}] {$classification} (出现 {$count} 次)\n";
if ($cause) {
$prompt .= " 可能原因: {$cause}\n";
}
if ($sample) {
// 限制样本长度,避免过长
$sampleTruncated = mb_strlen($sample) > 500 ? mb_substr($sample, 0, 500) . '...' : $sample;
$prompt .= " 日志样本: {$sampleTruncated}\n";
}
}
$prompt .= "\n";
} }
$prompt .= "请:\n"; $prompt .= "请:\n";
$prompt .= "1. 定位相关代码文件\n"; $prompt .= "1. 定位相关代码文件\n";
$prompt .= "2. 分析根本原因\n"; $prompt .= "2. 分析根本原因\n";
$prompt .= "3. 给出具体修复方案\n"; $prompt .= "3. 给出具体修复方案\n";
$prompt .= "\n注意:仅进行分析和提供建议,不要修改任何代码文件。\n";
return $prompt; return $prompt;
} }
@@ -151,24 +186,60 @@ class CodeAnalysisService
private function runTool(string $tool, string $workingDirectory, string $prompt): string private function runTool(string $tool, string $workingDirectory, string $prompt): string
{ {
return match ($tool) { return match ($tool) {
self::TOOL_CLAUDE => $this->runClaude($workingDirectory, $prompt),
self::TOOL_CODEX => $this->runCodex($workingDirectory, $prompt), self::TOOL_CODEX => $this->runCodex($workingDirectory, $prompt),
default => $this->runClaude($workingDirectory, $prompt), default => $this->runGemini($workingDirectory, $prompt),
}; };
} }
/**
* 执行 Gemini CLI 命令
*/
private function runGemini(string $workingDirectory, string $prompt): string
{
$process = new Process(
['gemini', '--approval-mode', 'plan', '-o', 'json', $prompt],
$workingDirectory,
$this->getEnvWithPath()
);
$process->setTimeout($this->timeout);
$process->mustRun();
$output = trim($process->getOutput());
// 解析 JSON 格式输出,提取完整的分析结果
$json = json_decode($output, true);
if ($json && isset($json['response'])) {
return $json['response'];
}
// 如果解析失败,返回原始输出
return $output;
}
/** /**
* 执行 Claude CLI 命令 * 执行 Claude CLI 命令
*/ */
private function runClaude(string $workingDirectory, string $prompt): string private function runClaude(string $workingDirectory, string $prompt): string
{ {
$process = new Process( $process = new Process(
['claude', '--print', $prompt], ['claude', '--print', '--output-format', 'json', $prompt],
$workingDirectory $workingDirectory,
$this->getEnvWithPath()
); );
$process->setTimeout($this->timeout); $process->setTimeout($this->timeout);
$process->mustRun(); $process->mustRun();
return trim($process->getOutput()); $output = trim($process->getOutput());
// 解析 JSON 格式输出,提取完整的分析结果
$json = json_decode($output, true);
if ($json && isset($json['result'])) {
return $json['result'];
}
// 如果解析失败,返回原始输出
return $output;
} }
/** /**
@@ -176,16 +247,100 @@ class CodeAnalysisService
*/ */
private function runCodex(string $workingDirectory, string $prompt): string private function runCodex(string $workingDirectory, string $prompt): string
{ {
// 使用临时文件保存最终消息,避免输出被截断
$outputFile = sys_get_temp_dir() . '/codex_output_' . uniqid() . '.txt';
$process = new Process( $process = new Process(
['codex', '--quiet', '--full-auto', $prompt], ['codex', 'exec', '--sandbox', 'read-only', '-o', $outputFile, $prompt],
$workingDirectory $workingDirectory,
$this->getEnvWithPath()
); );
$process->setTimeout($this->timeout); $process->setTimeout($this->timeout);
$process->mustRun(); $process->mustRun();
// 从输出文件读取完整结果
if (file_exists($outputFile)) {
$output = trim(file_get_contents($outputFile));
@unlink($outputFile);
return $output;
}
// 如果文件不存在,回退到标准输出
return trim($process->getOutput()); return trim($process->getOutput());
} }
/**
* 获取包含用户 PATH 的环境变量
* 确保 nvm、npm 全局安装的命令可以被找到
*/
private function getEnvWithPath(): array
{
$env = getenv();
$homeDir = getenv('HOME') ?: '/home/' . get_current_user();
// 添加常见的用户级 bin 目录到 PATH
$additionalPaths = [
"{$homeDir}/.local/bin",
"{$homeDir}/.npm-global/bin",
'/usr/local/bin',
];
// 查找 nvm 当前使用的 Node.js 版本的 bin 目录
$nvmDir = "{$homeDir}/.nvm/versions/node";
if (is_dir($nvmDir)) {
// 获取最新版本的 Node.js(按版本号排序)
$versions = @scandir($nvmDir);
if ($versions) {
$versions = array_filter($versions, fn($v) => $v !== '.' && $v !== '..');
if (!empty($versions)) {
usort($versions, 'version_compare');
$latestVersion = end($versions);
$additionalPaths[] = "{$nvmDir}/{$latestVersion}/bin";
}
}
}
$currentPath = $env['PATH'] ?? '/usr/bin:/bin';
$env['PATH'] = implode(':', $additionalPaths) . ':' . $currentPath;
// 添加 Gemini API Key(用于非交互式模式)
$geminiApiKey = $this->configService->get('log_analysis.gemini_api_key') ?: config('services.gemini.api_key');
if ($geminiApiKey) {
$env['GEMINI_API_KEY'] = $geminiApiKey;
}
// 确保代理环境变量被传递(后台任务可能没有继承)
$proxyUrl = config('services.proxy.url');
if ($proxyUrl) {
$env['HTTP_PROXY'] = $proxyUrl;
$env['HTTPS_PROXY'] = $proxyUrl;
$env['http_proxy'] = $proxyUrl;
$env['https_proxy'] = $proxyUrl;
}
return $env;
}
/**
* 设置 Gemini API Key
*/
public function setGeminiApiKey(string $apiKey): void
{
$this->configService->set(
'log_analysis.gemini_api_key',
$apiKey,
'Gemini CLI API Key (用于非交互式模式)'
);
}
/**
* 获取 Gemini API Key
*/
public function getGeminiApiKey(): ?string
{
return $this->configService->get('log_analysis.gemini_api_key') ?: config('services.gemini.api_key');
}
/** /**
* 设置超时时间 * 设置超时时间
*/ */
+5 -69
View File
@@ -11,8 +11,7 @@ class CodeContextService
private int $contextLines = 10; private int $contextLines = 10;
public function __construct( public function __construct(
private readonly ConfigService $configService, private readonly ConfigService $configService
private readonly EnvService $envService
) {} ) {}
/** /**
@@ -23,47 +22,14 @@ class CodeContextService
*/ */
public function getRepoPath(string $appName): ?string public function getRepoPath(string $appName): ?string
{ {
// 优先从 Project 模型查找 // 从 Project 模型查找,直接使用项目路径
$project = Project::findByAppName($appName); $project = Project::findByAppName($appName);
if ($project) { if ($project) {
$env = $project->log_env ?? 'production'; $projectsPath = $this->configService->get('workspace.projects_path', '');
try { if ($projectsPath && $project->isPathValid($projectsPath)) {
$envContent = $this->envService->getEnvContent($project->slug, $env); return $project->getFullPath($projectsPath);
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
if ($repoPath && is_dir($repoPath)) {
return $repoPath;
} }
} catch (\Exception $e) {
// 忽略错误,继续尝试旧配置
}
}
// 回退到旧的配置方式(兼容迁移前的情况)
$appEnvMap = $this->configService->get('log_analysis.app_env_map', []);
if (!isset($appEnvMap[$appName])) {
return null;
}
$mapping = $appEnvMap[$appName];
$projectSlug = $mapping['project'] ?? null;
$env = $mapping['env'] ?? null;
if (!$projectSlug || !$env) {
return null;
}
try {
$envContent = $this->envService->getEnvContent($projectSlug, $env);
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
if ($repoPath && is_dir($repoPath)) {
return $repoPath;
}
} catch (\Exception $e) {
// 忽略错误,返回 null
} }
return null; return null;
@@ -246,36 +212,6 @@ class CodeContextService
return null; return null;
} }
/**
* .env 内容中解析指定键的值
*
* @param string $envContent
* @param string $key
* @return string|null
*/
private function parseEnvValue(string $envContent, string $key): ?string
{
$lines = explode("\n", $envContent);
foreach ($lines as $line) {
$line = trim($line);
// 跳过注释和空行
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (str_starts_with($line, "{$key}=")) {
$value = substr($line, strlen($key) + 1);
// 移除引号
$value = trim($value, '"\'');
return $value ?: null;
}
}
return null;
}
/** /**
* 设置上下文行数 * 设置上下文行数
*/ */
+40 -5
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log;
class DingTalkService class DingTalkService
{ {
private ?string $webhook; private ?string $webhook;
private ?string $secret; private ?string $secret;
public function __construct() public function __construct()
@@ -25,9 +26,32 @@ class DingTalkService
'atMobiles' => $atMobiles, 'atMobiles' => $atMobiles,
'atAll' => $atAll, 'atAll' => $atAll,
]); ]);
return; 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 = [ $payload = [
'msgtype' => 'text', 'msgtype' => 'text',
'text' => [ 'text' => [
@@ -39,22 +63,33 @@ class DingTalkService
], ],
]; ];
$url = $this->webhook; $url = $webhook;
if (!empty($this->secret)) { if (! empty($secret)) {
$timestamp = (int) round(microtime(true) * 1000); $timestamp = (int) round(microtime(true) * 1000);
$stringToSign = $timestamp . "\n" . $this->secret; $stringToSign = $timestamp."\n".$secret;
$sign = base64_encode(hash_hmac('sha256', $stringToSign, $this->secret, true)); $sign = base64_encode(hash_hmac('sha256', $stringToSign, $secret, true));
$encodedSign = urlencode($sign); $encodedSign = urlencode($sign);
$separator = str_contains($url, '?') ? '&' : '?'; $separator = str_contains($url, '?') ? '&' : '?';
$url .= "{$separator}timestamp={$timestamp}&sign={$encodedSign}"; $url .= "{$separator}timestamp={$timestamp}&sign={$encodedSign}";
} }
try { 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) { } catch (\Throwable $e) {
Log::error('Failed to send DingTalk alert', [ Log::error('Failed to send DingTalk alert', [
'message' => $e->getMessage(), 'message' => $e->getMessage(),
]); ]);
} }
return false;
} }
} }
+220
View File
@@ -0,0 +1,220 @@
<?php
namespace App\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
class ErpRequestReportService
{
public const DINGTALK_TOKEN_CONFIG_KEY = 'erp_request_report.dingtalk_token';
private const REPORT_TIMEZONE = 'Asia/Shanghai';
// Keep a margin below DingTalk's text-message limit for transport overhead.
private const MAX_MESSAGE_BYTES = 18_000;
public function __construct(
private DatabaseManager $database,
private DingTalkService $dingTalkService,
private ConfigService $configService
) {}
/**
* @param string|null $date 单日(Y-m-d),与 from/to 互斥
* @param string|null $from 开始时间(Y-m-d Y-m-d H:i:s),含
* @param string|null $to 结束时间(Y-m-d Y-m-d H:i:s);仅日期时含整天,含时分秒时含该时刻
*/
public function sendReport(?string $date = null, ?string $from = null, ?string $to = null): array
{
$token = trim((string) $this->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<int, string>
*/
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}";
}
}
+20 -19
View File
@@ -620,6 +620,8 @@ class GitMonitorService
private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void
{ {
$path = $this->resolveProjectPath($repoKey, $repoConfig); $path = $this->resolveProjectPath($repoKey, $repoConfig);
$worktreePath = null;
$worktreeCreated = false;
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]); Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]);
@@ -636,34 +638,27 @@ class GitMonitorService
$version = str_replace('release/', '', $branch); $version = str_replace('release/', '', $branch);
try { try {
// 从 origin/master 创建新分支 // 在临时 worktree 中创建并推送分支,避免切换或修改用户正在工作的仓库。
$this->runGit($path, ['git', 'fetch', 'origin', 'master']); $this->runGit($path, ['git', 'fetch', 'origin', 'master']);
$worktreePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'toolbox-release-' . str_replace(['/', '\\'], '-', $repoKey . '-' . $version) . '-' . bin2hex(random_bytes(4));
// 创建本地分支(基于 origin/master $this->runGit($path, ['git', 'worktree', 'add', '--detach', $worktreePath, 'origin/master']);
try { $worktreeCreated = true;
// 先尝试删除可能存在的本地分支
$this->runGit($path, ['git', 'branch', '-D', $branch]);
} catch (ProcessFailedException) {
// 忽略,分支可能不存在
}
$this->runGit($path, ['git', 'checkout', '-b', $branch, 'origin/master']);
// 修改 version.txt 文件 // 修改 version.txt 文件
$versionFile = $path . DIRECTORY_SEPARATOR . 'version.txt'; $versionFile = $worktreePath . DIRECTORY_SEPARATOR . 'version.txt';
if (!file_put_contents($versionFile, $version)) { if (!file_put_contents($versionFile, $version)) {
throw new \RuntimeException("Failed to write version.txt"); throw new \RuntimeException("Failed to write version.txt");
} }
// 添加并提交更改 // 添加并提交更改
$this->runGit($path, ['git', 'add', 'version.txt']); $this->runGit($worktreePath, ['git', 'add', 'version.txt']);
// 构建提交信息:分支名 + 空格 + Jira 描述 // 构建提交信息:分支名 + 空格 + Jira 描述
$commitMessage = $branch . ($description ? ' ' . $description : ''); $commitMessage = $branch . ($description ? ' ' . $description : '');
$this->runGit($path, ['git', 'commit', '-m', $commitMessage]); $this->runGit($worktreePath, ['git', 'commit', '-m', $commitMessage]);
// 推送到远程 // 推送到远程
$this->runGit($path, ['git', 'push', '-u', 'origin', $branch]); $this->runGit($worktreePath, ['git', 'push', 'origin', 'HEAD:refs/heads/' . $branch]);
Log::info('Created and pushed release branch', [ Log::info('Created and pushed release branch', [
'repository' => $repoKey, 'repository' => $repoKey,
@@ -687,11 +682,17 @@ class GitMonitorService
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} finally { } finally {
// 切回 develop 分支,避免影响后续操作 if ($worktreeCreated && $worktreePath !== null) {
try { try {
$this->runGit($path, ['git', 'checkout', self::DEVELOP_BRANCH]); $this->runGit($path, ['git', 'worktree', 'remove', '--force', $worktreePath]);
} catch (ProcessFailedException) { } catch (ProcessFailedException $e) {
// 忽略 Log::warning('Failed to remove temporary release worktree', [
'repository' => $repoKey,
'branch' => $branch,
'path' => $worktreePath,
'error' => $e->getMessage(),
]);
}
} }
} }
} }
+294
View File
@@ -0,0 +1,294 @@
<?php
namespace App\Services;
use App\Clients\JenkinsClient;
use App\Models\JenkinsDeployment;
use App\Models\Project;
use Illuminate\Support\Facades\Log;
class JenkinsMonitorService
{
public function __construct(
private readonly JenkinsClient $jenkinsClient,
private readonly DingTalkService $dingTalkService,
private readonly ConfigService $configService
) {}
public function checkAllProjects(): array
{
if (!$this->jenkinsClient->isConfigured()) {
Log::warning('Jenkins client is not configured, skipping monitor');
return ['skipped' => true, 'reason' => 'Jenkins not configured'];
}
$projects = Project::getJenkinsNotifyEnabled();
$results = [];
foreach ($projects as $project) {
$results[$project->slug] = $this->checkProject($project);
}
return $results;
}
public function checkProject(Project $project): array
{
if (empty($project->jenkins_job_name)) {
return ['skipped' => true, 'reason' => 'No Jenkins job configured'];
}
$jobName = $project->jenkins_job_name;
$lastNotifiedBuild = $project->jenkins_last_notified_build ?? 0;
$allowedTriggers = $this->getAllowedTriggers();
$builds = $this->jenkinsClient->getBuilds($jobName, 5);
$newBuilds = [];
foreach ($builds as $build) {
// 只处理已完成的构建
if ($build['building'] ?? true) {
continue;
}
$buildNumber = $build['number'];
// 跳过已通知的构建
if ($buildNumber <= $lastNotifiedBuild) {
continue;
}
// 检查是否已存在记录
$exists = JenkinsDeployment::where('job_name', $jobName)
->where('build_number', $buildNumber)
->exists();
if ($exists) {
continue;
}
// 解析构建信息
$triggeredBy = $this->extractTriggeredBy($build);
$branch = $this->extractBranch($build);
$commitSha = $this->extractCommitSha($build);
$buildParams = $this->extractBuildParams($build);
// 过滤触发者(只通知指定用户触发的构建)
// 如果没有配置允许的触发者列表,则跳过所有通知
if (empty($allowedTriggers)) {
Log::info('Skipping build - no allowed triggers configured', [
'job' => $jobName,
'build' => $buildNumber,
'triggered_by' => $triggeredBy,
]);
continue;
}
// 检查触发者是否在允许列表中
if (!$this->isAllowedTrigger($triggeredBy, $allowedTriggers)) {
Log::info('Skipping build due to trigger filter', [
'job' => $jobName,
'build' => $buildNumber,
'triggered_by' => $triggeredBy,
]);
continue;
}
// 保存发布记录
$deployment = JenkinsDeployment::create([
'project_id' => $project->id,
'build_number' => $buildNumber,
'job_name' => $jobName,
'status' => $build['result'] ?? 'UNKNOWN',
'branch' => $branch,
'commit_sha' => $commitSha,
'triggered_by' => $triggeredBy,
'duration' => $build['duration'] ?? null,
'build_url' => $build['url'] ?? null,
'raw_data' => $build,
'build_params' => $buildParams,
'notified' => false,
]);
// 发送通知
$this->sendNotification($project, $deployment);
$deployment->update(['notified' => true]);
$newBuilds[] = $buildNumber;
}
// 更新最后通知的构建号
if (!empty($newBuilds)) {
$project->update([
'jenkins_last_notified_build' => max($newBuilds),
]);
}
return [
'job' => $jobName,
'new_builds' => $newBuilds,
];
}
private function sendNotification(Project $project, JenkinsDeployment $deployment): void
{
$lines = [];
$lines[] = sprintf(
"%s 【Jenkins 发布通知】",
$deployment->getStatusEmoji()
);
$lines[] = sprintf("项目: %s", $project->name);
$lines[] = sprintf("状态: %s", $deployment->getStatusLabel());
$lines[] = sprintf("构建号: #%d", $deployment->build_number);
$lines[] = sprintf("触发者: %s", $deployment->triggered_by ?? '-');
$lines[] = sprintf("耗时: %s", $deployment->getFormattedDuration());
// 添加构建参数
if (!empty($deployment->build_params)) {
$lines[] = "\n构建参数:";
foreach ($deployment->build_params as $key => $value) {
// 格式化参数值
if (is_bool($value)) {
$value = $value ? 'true' : 'false';
} elseif (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
} elseif ($value === null || $value === '') {
continue; // 跳过空值
}
$lines[] = sprintf(" %s: %s", $key, $value);
}
}
$lines[] = sprintf("\n详情: %s", $deployment->build_url ?? '-');
$message = implode("\n", $lines);
$this->dingTalkService->sendText($message);
}
private function extractTriggeredBy(array $build): ?string
{
$actions = $build['actions'] ?? [];
foreach ($actions as $action) {
// UserIdCause - 用户手动触发
if (isset($action['causes'])) {
foreach ($action['causes'] as $cause) {
if (isset($cause['userId'])) {
return $cause['userId'];
}
if (isset($cause['userName'])) {
return $cause['userName'];
}
}
}
}
return null;
}
private function extractBranch(array $build): ?string
{
$actions = $build['actions'] ?? [];
// 优先从参数中获取 branchName
foreach ($actions as $action) {
if (isset($action['parameters'])) {
foreach ($action['parameters'] as $param) {
if (in_array($param['name'] ?? '', ['branchName', 'BRANCH', 'branch', 'GIT_BRANCH', 'BRANCH_NAME'])) {
$value = $param['value'] ?? null;
if (!empty($value)) {
return $value;
}
}
}
}
}
// 如果参数中没有,再从 Git 分支信息中获取
foreach ($actions as $action) {
if (isset($action['lastBuiltRevision']['branch'])) {
foreach ($action['lastBuiltRevision']['branch'] as $branch) {
$name = $branch['name'] ?? '';
// 移除 origin/ 和 refs/remotes/origin/ 前缀
return preg_replace('/^(refs\/remotes\/origin\/|origin\/)/', '', $name);
}
}
}
return null;
}
private function extractCommitSha(array $build): ?string
{
$actions = $build['actions'] ?? [];
foreach ($actions as $action) {
if (isset($action['lastBuiltRevision']['SHA1'])) {
return substr($action['lastBuiltRevision']['SHA1'], 0, 8);
}
}
return null;
}
private function getAllowedTriggers(): array
{
$config = $this->configService->get('jenkins_allowed_triggers', []);
// 如果配置为空,返回空数组
if (empty($config)) {
return [];
}
// 如果配置是数组,直接返回(过滤空值)
if (is_array($config)) {
return array_filter(array_map('trim', $config));
}
// 如果配置是字符串(兼容旧格式),按逗号分隔
if (is_string($config)) {
return array_filter(array_map('trim', explode(',', $config)));
}
return [];
}
private function isAllowedTrigger(?string $triggeredBy, array $allowedTriggers): bool
{
if (empty($triggeredBy)) {
return false;
}
foreach ($allowedTriggers as $allowed) {
if (strcasecmp($triggeredBy, $allowed) === 0) {
return true;
}
}
return false;
}
private function extractBuildParams(array $build): array
{
$actions = $build['actions'] ?? [];
foreach ($actions as $action) {
// 查找 ParametersAction
if (isset($action['_class']) && $action['_class'] === 'hudson.model.ParametersAction') {
if (isset($action['parameters']) && is_array($action['parameters'])) {
$params = [];
foreach ($action['parameters'] as $param) {
$name = $param['name'] ?? null;
$value = $param['value'] ?? null;
if ($name !== null) {
$params[$name] = $value;
}
}
return $params;
}
}
}
return [];
}
}
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -34,10 +34,10 @@ class LogAnalysisService
AnalysisMode $mode = AnalysisMode::Logs, AnalysisMode $mode = AnalysisMode::Logs,
bool $pushNotification = false bool $pushNotification = false
): LogAnalysisReport { ): LogAnalysisReport {
// 如果没有指定查询条件,默认只获取 ERROR 和 WARNING 级别的日志 // 如果没有指定查询条件,默认只获取 ERROR 级别的日志
$effectiveQuery = $query; $effectiveQuery = $query;
if (empty($query)) { if (empty($query)) {
$effectiveQuery = 'ERROR or WARNING'; $effectiveQuery = 'content.level: ERROR';
} }
// 创建 pending 状态的报告 // 创建 pending 状态的报告
@@ -114,8 +114,7 @@ class LogAnalysisService
if (in_array($impact, ['high', 'medium'])) { if (in_array($impact, ['high', 'medium'])) {
$codeAnalysisResult = $this->codeAnalysisService->analyze( $codeAnalysisResult = $this->codeAnalysisService->analyze(
$appName, $appName,
$logsContent, $results[$appName]
$results[$appName]['summary'] ?? null
); );
$results[$appName]['code_analysis'] = $codeAnalysisResult; $results[$appName]['code_analysis'] = $codeAnalysisResult;
} }
+18 -47
View File
@@ -2,18 +2,18 @@
namespace App\Services; namespace App\Services;
use App\Clients\AgentClient; use App\Clients\MonoClient;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Carbon\Carbon; use Carbon\Carbon;
class MessageSyncService class MessageSyncService
{ {
private AgentClient $agentClient; private MonoClient $monoClient;
public function __construct(AgentClient $agentClient) public function __construct(MonoClient $monoClient)
{ {
$this->agentClient = $agentClient; $this->monoClient = $monoClient;
} }
/** /**
@@ -57,80 +57,51 @@ class MessageSyncService
} }
/** /**
* 批量同步消息到agent * 批量同步消息(通过mono消费)
*/ */
public function syncMessages(array $messageIds): array public function syncMessages(array $messageIds): array
{ {
$messages = $this->getMessagesByIds($messageIds);
$results = []; $results = [];
foreach ($messages as $message) { foreach ($messageIds as $msgId) {
$result = $this->syncSingleMessage($message); $results[] = $this->syncSingleMessage($msgId);
$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; return $results;
} }
/** /**
* 同步单个消息到agent * 通过mono消费单个消息
*/ */
private function syncSingleMessage(array $message): array private function syncSingleMessage(string $msgId): array
{ {
try { try {
$requestData = $this->buildAgentRequest($message); $response = $this->monoClient->consumeMessage($msgId);
$body = $response->json();
$response = $this->agentClient->dispatchMessage($requestData); if ($response->successful() && ($body['code'] ?? -1) === 0) {
if ($response->successful()) {
return [ return [
'msg_id' => $msgId,
'success' => true, 'success' => true,
'response' => $response->json(), 'response' => $body,
'request_data' => $requestData,
]; ];
} else { } else {
return [ return [
'msg_id' => $msgId,
'success' => false, 'success' => false,
'error' => 'HTTP ' . $response->status() . ': ' . $response->body(), 'error' => $body['message'] ?? ('HTTP ' . $response->status() . ': ' . $response->body()),
'request_data' => $requestData, 'response' => $body,
]; ];
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return [ return [
'msg_id' => $msgId,
'success' => false, 'success' => false,
'error' => '请求失败: ' . $e->getMessage(), '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字段 * 解析JSON字段
*/ */
+863
View File
@@ -0,0 +1,863 @@
<?php
namespace App\Services;
use App\Clients\CrmClient;
use App\Enums\CaseLabelBit;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 进产诊断服务
*
* 复刻 agent-be 中以下三处进产/放行判断逻辑,给出失败原因:
* - App\Services\AgentCase\ConfirmProduction::canProduction
* - App\Services\AgentBusinessDocument\ConfirmProduction::canProduction
* - App\Services\AgentSaleDocument\ConfirmPermit::canPermit
*
* 通过 agentslave 库直查所需表,并通过 CrmClient 调用 CRM 接口判断一级代理账期。
*/
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<int,string>
*/
private const DEFAULT_STUCK_PAYMENT_REASONS = [
CaseLabelBit::APPLIANCE_NEED_MONEY => '新病例进产',
CaseLabelBit::UPGRADE_NEED_MONEY => '转产品',
];
/** @var string agent-be 数据库连接名 */
private string $connection = 'agentslave';
/** @var string CRM 数据库连接名,用于回溯 label_bit 原始值 */
private string $crmConnection = 'crmslave';
public function __construct(private readonly CrmClient $crm) {}
/**
* 执行单次诊断
*/
public function diagnose(string $type, string $code): array
{
$code = trim($code);
$entity = $this->findEntity($type, $code);
if (! $entity) {
return [
'type' => $type,
'type_label' => $this->typeLabel($type),
'code' => $code,
'found' => false,
'message' => '未在 '.$this->tableFor($type).' 表中找到对应记录',
];
}
$operatorCode = (string) $entity->agent_code;
$operatorAgent = $this->findAgent($operatorCode);
$checks = [];
$pfpContext = null;
$checks['status'] = $this->checkStatus($type, $entity);
if ($type === self::TYPE_CASE) {
$pfpContext = $this->buildPfpContext($entity, $operatorCode);
$checks['need_pfp'] = $this->checkNeedPfp($pfpContext);
}
$checks['owner_agent'] = $this->checkOwnerAgent($type, $entity, $operatorCode);
$checks['credit'] = $this->checkCredit($entity, $operatorAgent, $operatorCode);
$canProduce = collect($checks)->every(fn ($c) => ($c['pass'] ?? false) === true);
return [
'type' => $type,
'type_label' => $this->typeLabel($type),
'code' => $code,
'found' => true,
'entity' => $this->normalizeEntity($type, $entity, $pfpContext),
'operating_agent_code' => $operatorCode,
'operating_agent' => $operatorAgent ? [
'code' => (string) $operatorAgent->code,
'name' => (string) ($operatorAgent->name ?? ''),
'level' => isset($operatorAgent->level) ? (int) $operatorAgent->level : null,
] : null,
'checks' => array_values($checks),
'can_production' => $canProduce,
];
}
// -----------------------------------------------------------------
// 实体查找
// -----------------------------------------------------------------
private function findEntity(string $type, string $code): ?object
{
return match ($type) {
self::TYPE_CASE => DB::connection($this->connection)
->table('cases')
->where('case_code', $code)
->where('deleted', 0)
->first(),
self::TYPE_BUSINESS => DB::connection($this->connection)
->table('business_documents')
->where('code', $code)
->where('deleted', 0)
->first(),
self::TYPE_SALE => DB::connection($this->connection)
->table('sale_documents')
->where('code', $code)
->where('deleted', 0)
->first(),
default => null,
};
}
private function findAgent(string $agentCode): ?object
{
if ($agentCode === '') {
return null;
}
return DB::connection($this->connection)
->table('agents')
->where('code', $agentCode)
->where('deleted', 0)
->first();
}
// -----------------------------------------------------------------
// 各项检查
// -----------------------------------------------------------------
/**
* 状态检查 - 三类单据的「期望状态」不同
*/
private function checkStatus(string $type, object $entity): array
{
[$expectedStatus, $expectedLabel] = $this->expectedStatusFor($type);
$actualStatus = (int) $entity->status;
$pass = $actualStatus === $expectedStatus;
return [
'key' => 'status',
'label' => '状态检查',
'pass' => $pass,
'expected' => sprintf('%s (%d)', $expectedLabel, $expectedStatus),
'actual' => sprintf('%s (%d)', $this->statusLabel($type, $actualStatus), $actualStatus),
'detail' => $pass
? '单据状态符合进产条件'
: '单据状态不在「'.$expectedLabel.'」,无法进产',
'hint' => $pass ? null : '需等待单据流转至「'.$expectedLabel.'」后才能进产',
];
}
/**
* 进产原因检查 - case.is_need_pfp 位图
*
* is_need_pfp 来源于 CRM ea_case_cstm.label_bit,经 stuck_payment_reason
* 配置过滤后写入代理库;只有存在卡款原因的病例才会走代理端进产流程。
*
* @param array<string,mixed> $ctx buildPfpContext 的返回值
*/
private function checkNeedPfp(array $ctx): array
{
$pass = $ctx['is_need_pfp'] > 0;
$reasonText = $ctx['reason_text'];
$actual = $pass
? sprintf('进产原因:%sis_need_pfp = %d', $reasonText, $ctx['is_need_pfp'])
: sprintf('无进产原因(is_need_pfp = 0);放行状态:%s', $ctx['is_pfp_text']);
return [
'key' => 'need_pfp',
'label' => '进产原因检查',
'pass' => $pass,
'expected' => '病例存在卡生产原因('.$this->configOptionText($ctx).'',
'actual' => $actual,
'detail' => $this->pfpDetail($ctx, $pass),
'hint' => $pass ? null : $this->pfpHint($ctx),
] + $ctx;
}
/**
* 汇总进产原因所需的全部上下文:代理库位图、配置项、CRM 原始 label_bit
*
* @return array<string,mixed>
*/
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<int,string> $configReasons
* @return array<int,array<string,mixed>>
*/
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<int,string>, 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<int,string>
*/
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<int,string> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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
* 2) 结算代理表中存在 (code = entity.code, agent_code = operatorCode, deleted = 0)
*/
private function checkOwnerAgent(string $type, object $entity, string $operatorCode): array
{
$entityAgentCode = (string) $entity->agent_code;
$settlementTable = $this->settlementTableFor($type);
$entityCode = $this->primaryCodeOf($type, $entity);
$exists = DB::connection($this->connection)
->table($settlementTable)
->where('code', $entityCode)
->where('agent_code', $operatorCode)
->where('deleted', 0)
->exists();
$pass = $entityAgentCode === $operatorCode && $entityAgentCode !== '' && $exists;
$detail = match (true) {
$entityAgentCode === '' => '归属代理 agent_code 为空,无法定位操作代理',
$entityAgentCode !== $operatorCode => '当前操作代理 '.$operatorCode.' 与单据归属代理 '.$entityAgentCode.' 不一致',
! $exists => '结算代理表 '.$settlementTable.' 中未找到 code='.$entityCode.', agent_code='.$operatorCode.' 的有效记录',
default => '操作代理为归属代理,且在结算代理表中存在有效记录',
};
return [
'key' => 'owner_agent',
'label' => '归属代理权限',
'pass' => $pass,
'expected' => '操作代理 = 单据 agent_code,且在 '.$settlementTable.' 中 deleted=0 存在记录',
'actual' => sprintf(
'单据 agent_code=%s,结算代理表存在=%s',
$entityAgentCode === '' ? '(空)' : $entityAgentCode,
$exists ? '是' : '否'
),
'detail' => $detail,
'hint' => $pass ? null : '检查 '.$settlementTable.' 的记录是否被软删或代理归属是否被调整',
];
}
/**
* 账期检查 - 完整复现 AgentCredit::getLastCreditAgentCode 逻辑
*
* 通过 agent_agents 取链路(root ... operator),逐级查 contracts
* 一级代理账期通过 CRM 接口 /api/group/detail/{code} 取得
*/
private function checkCredit(object $entity, ?object $operatorAgent, string $operatorCode): array
{
$productCode = (string) ($entity->product_code ?? '');
if (! $operatorAgent) {
return [
'key' => 'credit',
'label' => '账期检查',
'pass' => false,
'expected' => '最后一级有账期的代理 = 单据 agent_code',
'actual' => '未找到归属代理 '.$operatorCode.' 的代理记录',
'detail' => 'agents 表中查不到该代理,无法计算账期链路',
'hint' => '确认 agents 表中是否存在该 code 且 deleted=0',
];
}
if ($productCode === '') {
return [
'key' => 'credit',
'label' => '账期检查',
'pass' => false,
'expected' => '存在 product_code 才能判断账期',
'actual' => 'product_code 为空',
'detail' => '单据未关联 product_code,账期无法判断',
'hint' => '检查单据数据是否完整',
];
}
$relation = DB::connection($this->connection)
->table('agent_agents')
->where('agent_code', $operatorCode)
->where('deleted', 0)
->first();
if (! $relation) {
return [
'key' => 'credit',
'label' => '账期检查',
'pass' => false,
'expected' => '存在 agent_agents 链路',
'actual' => 'agent_agents 中无 '.$operatorCode.' 的记录',
'detail' => '无法构建代理链路,AgentCredit 直接返回空,账期判断必然失败',
'hint' => '检查 agent_agents 数据是否同步',
];
}
$rootAgentCode = (string) $relation->root_agent_code;
// 取整条链路 root -> operator,按 lft 升序
$chain = DB::connection($this->connection)
->table('agent_agents')
->where('root_agent_code', $rootAgentCode)
->where('lft', '<=', (int) $relation->lft)
->where('rgt', '>=', (int) $relation->rgt)
->where('deleted', 0)
->orderBy('lft')
->get();
if ($chain->isEmpty()) {
return [
'key' => 'credit',
'label' => '账期检查',
'pass' => false,
'expected' => '存在代理链路',
'actual' => '代理链路为空',
'detail' => '无法构建代理链路',
'hint' => '检查 agent_agents 数据',
];
}
// 一级代理账期 - 调 CRM
$firstAgentCreditMap = $this->crm->isConfigured()
? $this->crm->firstAgentCreditMap($rootAgentCode)
: null;
$crmConfigured = $this->crm->isConfigured();
$firstAgentHasCredit = $firstAgentCreditMap !== null
&& isset($firstAgentCreditMap[$productCode])
&& $firstAgentCreditMap[$productCode] === true;
// 子级代理逐级取合同 is_credit
$subChain = $chain->slice(1)->values();
$chainEvaluation = $this->evaluateChain($rootAgentCode, $subChain, $productCode, $firstAgentHasCredit);
$lastCreditAgentCode = $chainEvaluation['last_credit_agent_code'];
$pass = $lastCreditAgentCode !== '' && $lastCreditAgentCode === $operatorCode;
$detail = match (true) {
! $crmConfigured => 'CRM 接口未配置(CRM_SERVICE_BASE_URI),一级代理账期视为「未知」,链路计算可能与生产不一致',
$firstAgentCreditMap === null => '调用 CRM 一级代理详情失败,无法判断一级代理账期',
! $firstAgentHasCredit => '一级代理 '.$rootAgentCode.' 在产品 '.$productCode.' 上无账期,AgentCredit 返回空,账期判断必然失败',
$lastCreditAgentCode === '' => '账期链路计算结果为空',
$pass => '最后一级有账期的代理 = 单据归属代理('.$operatorCode.'',
default => '最后一级有账期的代理为 '.$lastCreditAgentCode.',与单据归属代理 '.$operatorCode.' 不一致',
};
return [
'key' => 'credit',
'label' => '账期检查',
'pass' => $pass,
'expected' => '最后一级有账期的代理 = '.$operatorCode,
'actual' => '最后一级有账期的代理 = '.($lastCreditAgentCode === '' ? '(空)' : $lastCreditAgentCode),
'detail' => $detail,
'hint' => $pass ? null : $this->creditHint($crmConfigured, $firstAgentCreditMap, $firstAgentHasCredit),
'chain' => $chainEvaluation['chain'],
'product_code' => $productCode,
'root_agent_code' => $rootAgentCode,
'crm_configured' => $crmConfigured,
'first_agent_credit_resolved' => $firstAgentCreditMap !== null,
'first_agent_has_credit' => $firstAgentHasCredit,
];
}
/**
* 复现 AgentCredit::getLastCreditAgentCode 中遍历子代理的部分
*
* @return array{last_credit_agent_code:string, chain:array<int,array<string,mixed>>}
*/
private function evaluateChain(string $rootAgentCode, \Illuminate\Support\Collection $subChain, string $productCode, bool $firstAgentHasCredit): array
{
$chainView = [];
// root 节点
$rootAgent = $this->findAgent($rootAgentCode);
$chainView[] = [
'agent_code' => $rootAgentCode,
'agent_name' => $rootAgent->name ?? null,
'level' => $rootAgent ? (int) $rootAgent->level : null,
'is_root' => true,
'has_credit' => $firstAgentHasCredit,
'credit_source' => 'crm:getAgentByCode',
];
if (! $firstAgentHasCredit) {
return [
'last_credit_agent_code' => '',
'chain' => $chainView,
];
}
$lastCreditAgentCode = $rootAgentCode;
$broken = false;
foreach ($subChain as $node) {
$agentCode = (string) $node->agent_code;
$agent = $this->findAgent($agentCode);
$hasCredit = $this->subAgentHasCredit($agentCode, $productCode);
$chainView[] = [
'agent_code' => $agentCode,
'agent_name' => $agent->name ?? null,
'level' => $agent ? (int) $agent->level : null,
'is_root' => false,
'has_credit' => $hasCredit,
'credit_source' => 'db:agent_contracts',
'broken' => $broken,
];
if ($broken) {
continue;
}
if (! $hasCredit) {
$broken = true;
continue;
}
$lastCreditAgentCode = $agentCode;
}
return [
'last_credit_agent_code' => $lastCreditAgentCode,
'chain' => $chainView,
];
}
/**
* 子代理在某产品上是否有账期 - 复现 AgentCredit::get
*
* agent_contracts JOIN contracts WHERE contracts.status = ENABLE
* 然后取 product_code 对应的 is_credit > 0
*/
private function subAgentHasCredit(string $agentCode, string $productCode): bool
{
// ContractModel::STATUS_ENABLE = 1
$contracts = DB::connection($this->connection)
->table('agent_contracts as ac')
->join('contracts as c', 'c.id', '=', 'ac.contract_id')
->where('ac.agent_code', $agentCode)
->where('ac.deleted', 0)
->where('c.deleted', 0)
->where('c.status', 1)
->where('c.product_code', $productCode)
->select('c.is_credit')
->get();
if ($contracts->isEmpty()) {
return false;
}
// 与 AgentCredit::get 一致:取该 product 下最后一个值(map 覆盖)
$hasCredit = false;
foreach ($contracts as $row) {
$hasCredit = ((int) $row->is_credit) > 0;
}
return $hasCredit;
}
// -----------------------------------------------------------------
// 标签与映射
// -----------------------------------------------------------------
private function expectedStatusFor(string $type): array
{
return match ($type) {
// CaseEnum::STATUS_3D_CONFIRMED
self::TYPE_CASE => [12, '3D设计已确认'],
// BusinessDocumentEnum::STATUS_TO_BE_PAYMENT
self::TYPE_BUSINESS => [5, '异常暂停(款项待支付)'],
// SaleDocumentEnum::STATUS_WAIT_PERMIT
self::TYPE_SALE => [3, '待放行'],
};
}
private function statusLabel(string $type, int $status): string
{
$map = match ($type) {
self::TYPE_CASE => [
1 => '资料处理中',
2 => '文字方案设计中',
3 => '文字方案待确认',
4 => '3D设计中',
5 => '3D设计待确认',
6 => '加工中',
7 => '已发货',
8 => '暂停',
9 => '结束',
10 => '不收治',
11 => '文字方案已确认',
12 => '3D设计已确认',
20 => '目标位设计中',
21 => '目标位待确认',
22 => '目标位已确认',
30 => '产品待确认',
31 => '产品已确认',
],
self::TYPE_BUSINESS => [
1 => '资料未收到',
2 => '资料处理中',
3 => '风险待确认',
4 => '风险已确认',
5 => '异常暂停(款项待支付)',
9 => '加工中',
10 => '已发货',
11 => '暂停',
12 => '终止',
],
self::TYPE_SALE => [
1 => '新建',
2 => '待付款',
3 => '待放行',
4 => '待发货',
5 => '已发货',
6 => '待审批',
7 => '审批拒绝',
8 => '部分发货',
],
};
return $map[$status] ?? '未知状态';
}
private function typeLabel(string $type): string
{
return match ($type) {
self::TYPE_CASE => '病例',
self::TYPE_BUSINESS => '业务单据',
self::TYPE_SALE => '销售单据',
};
}
private function tableFor(string $type): string
{
return match ($type) {
self::TYPE_CASE => 'cases',
self::TYPE_BUSINESS => 'business_documents',
self::TYPE_SALE => 'sale_documents',
};
}
private function settlementTableFor(string $type): string
{
return match ($type) {
self::TYPE_CASE => 'settlement_agent_case',
self::TYPE_BUSINESS => 'settlement_agent_business_order',
self::TYPE_SALE => 'settlement_agent_sales_order',
};
}
private function primaryCodeOf(string $type, object $entity): string
{
return $type === self::TYPE_CASE ? (string) $entity->case_code : (string) $entity->code;
}
/**
* @param array<string,mixed>|null $pfpContext
*/
private function normalizeEntity(string $type, object $entity, ?array $pfpContext = null): array
{
$base = [
'status' => (int) $entity->status,
'status_label' => $this->statusLabel($type, (int) $entity->status),
'agent_code' => (string) ($entity->agent_code ?? ''),
'settlement_agent_code' => (string) ($entity->settlement_agent_code ?? ''),
'product_code' => (string) ($entity->product_code ?? ''),
];
if ($type === self::TYPE_CASE) {
return $base + [
'code' => (string) $entity->case_code,
'hospital_code' => (string) ($entity->hospital_code ?? ''),
'doctor_code' => (string) ($entity->doctor_code ?? ''),
'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'] ?? '未知',
];
}
return $base + [
'code' => (string) $entity->code,
'hospital_code' => (string) ($entity->hospital_code ?? ''),
];
}
private function creditHint(bool $crmConfigured, ?array $firstAgentCreditMap, bool $firstAgentHasCredit): string
{
if (! $crmConfigured) {
return '配置 .env 中的 CRM_SERVICE_BASE_URI 后可获得准确的一级代理账期判断';
}
if ($firstAgentCreditMap === null) {
return 'CRM 接口调用失败,可查看 laravel.log';
}
if (! $firstAgentHasCredit) {
return '需在 CRM 「集团详情」productList 中确认该产品的 agentAccountingPeriod > 0';
}
return '检查链路中各代理的 agent_contracts / contracts 是否启用了对应产品账期';
}
}
+35 -4
View File
@@ -23,6 +23,7 @@ class ScheduledTaskService
try { try {
self::$configServiceInstance ??= app(ConfigService::class); self::$configServiceInstance ??= app(ConfigService::class);
$enabled = self::$configServiceInstance->get(self::CONFIG_KEY, []); $enabled = self::$configServiceInstance->get(self::CONFIG_KEY, []);
return $enabled[$name] ?? false; return $enabled[$name] ?? false;
} catch (\Exception $e) { } catch (\Exception $e) {
return false; return false;
@@ -44,7 +45,7 @@ class ScheduledTaskService
$tasks[] = [ $tasks[] = [
'name' => $name, 'name' => $name,
'command' => $this->getEventCommand($event), 'command' => $this->getEventCommand($event),
'description' => $event->description ?: $name, 'description' => $this->getTaskDescription($name),
'frequency' => $this->getFrequencyLabel($event->expression), 'frequency' => $this->getFrequencyLabel($event->expression),
'cron' => $event->expression, 'cron' => $event->expression,
'enabled' => $enabledTasks[$name] ?? false, 'enabled' => $enabledTasks[$name] ?? false,
@@ -70,7 +71,7 @@ class ScheduledTaskService
} }
} }
if (!$exists) { if (! $exists) {
throw new \InvalidArgumentException("未知任务: {$name}"); throw new \InvalidArgumentException("未知任务: {$name}");
} }
@@ -92,9 +93,15 @@ class ScheduledTaskService
private function getEventName($event): string private function getEventName($event): string
{ {
if (property_exists($event, 'mutexName') && $event->mutexName) { // Laravel Schedule 事件的 description 属性存储任务名称
return $event->mutexName; // 我们在 routes/console.php 中通过 ->description() 设置
// 1. 优先使用 description (我们设置的任务标识符)
if (property_exists($event, 'description') && $event->description) {
return $event->description;
} }
// 2. 最后使用命令作为名称
return $this->getEventCommand($event); return $this->getEventCommand($event);
} }
@@ -105,8 +112,10 @@ class ScheduledTaskService
if (str_contains($command, 'artisan')) { if (str_contains($command, 'artisan')) {
$command = preg_replace('/^.*artisan\s+/', '', $command); $command = preg_replace('/^.*artisan\s+/', '', $command);
} }
return trim(str_replace("'", '', $command)); return trim(str_replace("'", '', $command));
} }
return 'closure'; return 'closure';
} }
@@ -125,9 +134,31 @@ class ScheduledTaskService
'0 */12 * * *' => '每 12 小时', '0 */12 * * *' => '每 12 小时',
'0 0 * * *' => '每天凌晨 0:00', '0 0 * * *' => '每天凌晨 0:00',
'0 2 * * *' => '每天凌晨 2:00', '0 2 * * *' => '每天凌晨 2:00',
'0 3 * * *' => '每天凌晨 3:00',
'0 8 * * *' => '每天早上 08:00',
'0 0 * * 0' => '每周日凌晨', '0 0 * * 0' => '每周日凌晨',
'0 0 1 * *' => '每月 1 日凌晨', '0 0 1 * *' => '每月 1 日凌晨',
]; ];
return $map[$expression] ?? $expression; return $map[$expression] ?? $expression;
} }
/**
* 获取任务的友好描述文本
*/
private function getTaskDescription(string $name): string
{
$descriptions = [
'git-monitor-check' => 'Git 监控 - 检查 release 分支变化',
'git-monitor-cache' => 'Git 监控 - 刷新 release 缓存',
'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志',
'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志',
'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知',
'erp-request-report' => 'ERP 请求日报 - 汇总前一天 OpenAPI 请求并发送钉钉',
'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表',
'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志',
];
return $descriptions[$name] ?? $name;
}
} }
+2
View File
@@ -12,6 +12,8 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->append(\App\Http\Middleware\HostAccessMiddleware::class);
$middleware->alias([ $middleware->alias([
'admin.ip' => \App\Http\Middleware\AdminIpMiddleware::class, 'admin.ip' => \App\Http\Middleware\AdminIpMiddleware::class,
]); ]);
+8 -1
View File
@@ -13,6 +13,7 @@
"lesstif/php-jira-rest-client": "5.10.0" "lesstif/php-jira-rest-client": "5.10.0"
}, },
"require-dev": { "require-dev": {
"cweagans/composer-patches": "*",
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.13", "laravel/pint": "^1.13",
@@ -61,6 +62,11 @@
"extra": { "extra": {
"laravel": { "laravel": {
"dont-discover": [] "dont-discover": []
},
"patches": {
"alibabacloud/aliyun-log-php-sdk": {
"Fix PHP 8.x CurlHandle cannot be converted to string": "patches/aliyun-log-php-sdk-php8-fix.patch"
}
} }
}, },
"config": { "config": {
@@ -69,7 +75,8 @@
"sort-packages": true, "sort-packages": true,
"allow-plugins": { "allow-plugins": {
"pestphp/pest-plugin": true, "pestphp/pest-plugin": true,
"php-http/discovery": true "php-http/discovery": true,
"cweagans/composer-patches": true
} }
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
Generated
+124 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c0be44d46402c6a66259be9824335576", "content-hash": "e66630836dd52f91ae3b422e8187ed3c",
"packages": [ "packages": [
{ {
"name": "alibabacloud/aliyun-log-php-sdk", "name": "alibabacloud/aliyun-log-php-sdk",
@@ -5952,6 +5952,129 @@
} }
], ],
"packages-dev": [ "packages-dev": [
{
"name": "cweagans/composer-configurable-plugin",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/cweagans/composer-configurable-plugin.git",
"reference": "15433906511a108a1806710e988629fd24b89974"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/cweagans/composer-configurable-plugin/zipball/15433906511a108a1806710e988629fd24b89974",
"reference": "15433906511a108a1806710e988629fd24b89974",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"require-dev": {
"codeception/codeception": "~4.0",
"codeception/module-asserts": "^2.0",
"composer/composer": "~2.0",
"php-coveralls/php-coveralls": "~2.0",
"php-parallel-lint/php-parallel-lint": "^1.0.0",
"phpro/grumphp": "^1.8.0",
"sebastian/phpcpd": "^6.0",
"squizlabs/php_codesniffer": "^3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"cweagans\\Composer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Cameron Eagans",
"email": "me@cweagans.net"
}
],
"description": "Provides a lightweight configuration system for Composer plugins.",
"support": {
"issues": "https://github.com/cweagans/composer-configurable-plugin/issues",
"source": "https://github.com/cweagans/composer-configurable-plugin/tree/2.0.0"
},
"funding": [
{
"url": "https://github.com/cweagans",
"type": "github"
}
],
"time": "2023-02-12T04:58:58+00:00"
},
{
"name": "cweagans/composer-patches",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/cweagans/composer-patches.git",
"reference": "bfa6018a5f864653d9ed899b902ea72f858a2cf7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/cweagans/composer-patches/zipball/bfa6018a5f864653d9ed899b902ea72f858a2cf7",
"reference": "bfa6018a5f864653d9ed899b902ea72f858a2cf7",
"shasum": ""
},
"require": {
"composer-plugin-api": "^2.0",
"cweagans/composer-configurable-plugin": "^2.0",
"ext-json": "*",
"php": ">=8.0.0"
},
"require-dev": {
"codeception/codeception": "~4.0",
"codeception/module-asserts": "^2.0",
"codeception/module-cli": "^2.0",
"codeception/module-filesystem": "^2.0",
"composer/composer": "~2.0",
"php-coveralls/php-coveralls": "~2.0",
"php-parallel-lint/php-parallel-lint": "^1.0.0",
"phpro/grumphp": "^1.8.0",
"sebastian/phpcpd": "^6.0",
"squizlabs/php_codesniffer": "^4.0"
},
"type": "composer-plugin",
"extra": {
"_": "The following two lines ensure that composer-patches is loaded as early as possible.",
"class": "cweagans\\Composer\\Plugin\\Patches",
"plugin-modifies-downloads": true,
"plugin-modifies-install-path": true
},
"autoload": {
"psr-4": {
"cweagans\\Composer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Cameron Eagans",
"email": "me@cweagans.net"
}
],
"description": "Provides a way to patch Composer packages.",
"support": {
"issues": "https://github.com/cweagans/composer-patches/issues",
"source": "https://github.com/cweagans/composer-patches/tree/2.0.0"
},
"funding": [
{
"url": "https://github.com/cweagans",
"type": "github"
}
],
"time": "2025-10-30T23:44:22+00:00"
},
{ {
"name": "fakerphp/faker", "name": "fakerphp/faker",
"version": "v1.24.1", "version": "v1.24.1",
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'host' => env('JENKINS_HOST'),
'username' => env('JENKINS_USERNAME'),
'api_token' => env('JENKINS_API_TOKEN'),
'timeout' => (int) env('JENKINS_TIMEOUT', 30),
];
+40
View File
@@ -127,6 +127,46 @@ return [
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
], ],
'jenkins-monitor' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/jenkins-monitor.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'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'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'log-analysis' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/log-analysis.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'scheduled-tasks' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/scheduled-tasks.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
], ],
]; ];
+13
View File
@@ -45,6 +45,11 @@ return [
'timeout' => env('MONO_TIMEOUT', 30), 'timeout' => env('MONO_TIMEOUT', 30),
], ],
'crm' => [
'base_uri' => env('CRM_SERVICE_BASE_URI'),
'timeout' => (int) env('CRM_SERVICE_TIMEOUT', 15),
],
'dingtalk' => [ 'dingtalk' => [
'webhook' => env('DINGTALK_WEBHOOK'), 'webhook' => env('DINGTALK_WEBHOOK'),
'secret' => env('DINGTALK_SECRET'), 'secret' => env('DINGTALK_SECRET'),
@@ -69,4 +74,12 @@ return [
'max_tokens' => (int) env('AI_MAX_TOKENS', 4096), 'max_tokens' => (int) env('AI_MAX_TOKENS', 4096),
], ],
'gemini' => [
'api_key' => env('GEMINI_API_KEY'),
],
'proxy' => [
'url' => env('PROXY_URL'),
],
]; ];
+2 -1
View File
@@ -1,8 +1,9 @@
<?php <?php
return [ return [
'admin_host' => strtolower((string) env('TOOLBOX_ADMIN_HOST', 'toolbox.local')),
'admin_ips' => array_values(array_filter(array_map( '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', '')) explode(',', (string) env('TOOLBOX_ADMIN_IPS', ''))
))), ))),
'operation_log' => [ 'operation_log' => [
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->string('jenkins_job_name', 255)->nullable()->comment('Jenkins Job 名称');
$table->boolean('jenkins_notify_enabled')->default(false)->comment('是否启用 Jenkins 通知');
$table->integer('jenkins_last_notified_build')->nullable()->comment('最后通知的构建号');
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn(['jenkins_job_name', 'jenkins_notify_enabled', 'jenkins_last_notified_build']);
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('jenkins_deployments', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->nullable()->constrained()->onDelete('cascade');
$table->integer('build_number');
$table->string('job_name', 255);
$table->string('status', 20)->comment('SUCCESS, FAILURE, ABORTED, UNSTABLE');
$table->string('branch', 255)->nullable();
$table->string('commit_sha', 64)->nullable();
$table->string('triggered_by', 100)->nullable();
$table->integer('duration')->nullable()->comment('构建耗时(毫秒)');
$table->string('build_url', 500)->nullable();
$table->json('raw_data')->nullable();
$table->boolean('notified')->default(false);
$table->timestamps();
$table->unique(['job_name', 'build_number']);
$table->index(['project_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('jenkins_deployments');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('scheduled_tasks', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->comment('任务唯一标识符');
$table->string('command')->comment('任务命令');
$table->string('description')->nullable()->comment('任务描述');
$table->string('frequency')->comment('执行频率描述');
$table->string('cron')->comment('Cron 表达式');
$table->boolean('enabled')->default(false)->comment('是否启用');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('scheduled_tasks');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('jenkins_deployments', function (Blueprint $table) {
$table->json('build_params')->nullable()->after('raw_data')->comment('构建参数');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('jenkins_deployments', function (Blueprint $table) {
$table->dropColumn('build_params');
});
}
};
+17
View File
@@ -0,0 +1,17 @@
{
"_hash": "9bce1dd342959a98713ba4689644c1aace66eb0fbe029720f51715c3f5841ba0",
"patches": {
"alibabacloud/aliyun-log-php-sdk": [
{
"package": "alibabacloud/aliyun-log-php-sdk",
"description": "Fix PHP 8.x CurlHandle cannot be converted to string",
"url": "patches/aliyun-log-php-sdk-php8-fix.patch",
"sha256": "45572f8024eb66fd70902e03deb5c5ee90a735d6dcec180bf7264a4b2e7183af",
"depth": 1,
"extra": {
"provenance": "root"
}
}
]
}
}
+20
View File
@@ -0,0 +1,20 @@
--- a/Aliyun/Log/requestcore.class.php
+++ b/Aliyun/Log/requestcore.class.php
@@ -832,7 +832,7 @@
if ($this->response === false)
{
- throw new RequestCore_Exception('cURL resource: ' . (string) $curl_handle . '; cURL error: ' . curl_error($curl_handle) . ' (' . curl_errno($curl_handle) . ')');
+ throw new RequestCore_Exception('cURL error: ' . curl_error($curl_handle) . ' (' . curl_errno($curl_handle) . ')');
}
$parsed_response = $this->process_response($curl_handle, $this->response);
@@ -905,7 +905,7 @@
// Since curl_errno() isn't reliable for handles that were in multirequests, we check the 'result' of the info read, which contains the curl error number, (listed here http://curl.haxx.se/libcurl/c/libcurl-errors.html )
if ($done['result'] > 0)
{
- throw new RequestCore_Exception('cURL resource: ' . (string) $done['handle'] . '; cURL error: ' . curl_error($done['handle']) . ' (' . $done['result'] . ')');
+ throw new RequestCore_Exception('cURL error: ' . curl_error($done['handle']) . ' (' . $done['result'] . ')');
}
// Because curl_multi_info_read() might return more than one message about a request, we check to see if this request is already in our array of completed requests
+1
View File
@@ -30,5 +30,6 @@
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="TOOLBOX_ADMIN_HOST" value="localhost"/>
</php> </php>
</phpunit> </phpunit>
@@ -16,12 +16,25 @@
ref="weeklyReport" ref="weeklyReport"
/> />
<!-- 提测邮件生成页面 -->
<test-mail-generator
v-else-if="currentPage === 'test-mail'"
ref="testMailGenerator"
:is-admin="isAdmin"
/>
<!-- SQL 生成页面 --> <!-- SQL 生成页面 -->
<sql-generator <sql-generator
v-else-if="currentPage === 'sql-generator'" v-else-if="currentPage === 'sql-generator'"
ref="sqlGenerator" ref="sqlGenerator"
/> />
<!-- 进产诊断页面 -->
<production-diagnosis
v-else-if="currentPage === 'production-diagnosis'"
ref="productionDiagnosis"
/>
<!-- JIRA 工时查询页面 --> <!-- JIRA 工时查询页面 -->
<jira-worklog <jira-worklog
v-else-if="currentPage === 'worklog'" v-else-if="currentPage === 'worklog'"
@@ -66,6 +79,9 @@
<!-- 定时任务管理页面 --> <!-- 定时任务管理页面 -->
<scheduled-tasks v-else-if="currentPage === 'scheduled-tasks'" /> <scheduled-tasks v-else-if="currentPage === 'scheduled-tasks'" />
<!-- Jenkins 一键构建页面 -->
<jenkins-builds v-else-if="currentPage === 'jenkins-builds'" />
</admin-layout> </admin-layout>
</template> </template>
@@ -73,7 +89,9 @@
import AdminLayout from './AdminLayout.vue'; import AdminLayout from './AdminLayout.vue';
import EnvManagement from '../env/EnvManagement.vue'; import EnvManagement from '../env/EnvManagement.vue';
import WeeklyReport from '../jira/WeeklyReport.vue'; import WeeklyReport from '../jira/WeeklyReport.vue';
import TestMailGenerator from '../jira/TestMailGenerator.vue';
import SqlGenerator from '../tools/SqlGenerator.vue'; import SqlGenerator from '../tools/SqlGenerator.vue';
import ProductionDiagnosis from '../tools/ProductionDiagnosis.vue';
import JiraWorklog from '../jira/JiraWorklog.vue'; import JiraWorklog from '../jira/JiraWorklog.vue';
import MessageSync from '../message-sync/MessageSync.vue'; import MessageSync from '../message-sync/MessageSync.vue';
import EventConsumerSync from '../message-sync/EventConsumerSync.vue'; import EventConsumerSync from '../message-sync/EventConsumerSync.vue';
@@ -84,6 +102,7 @@ import OperationLogs from './OperationLogs.vue';
import IpUserMappings from './IpUserMappings.vue'; import IpUserMappings from './IpUserMappings.vue';
import ProjectManagement from './ProjectManagement.vue'; import ProjectManagement from './ProjectManagement.vue';
import ScheduledTasks from './ScheduledTasks.vue'; import ScheduledTasks from './ScheduledTasks.vue';
import JenkinsBuilds from './JenkinsBuilds.vue';
export default { export default {
name: 'AdminDashboard', name: 'AdminDashboard',
@@ -91,7 +110,9 @@ export default {
AdminLayout, AdminLayout,
EnvManagement, EnvManagement,
WeeklyReport, WeeklyReport,
TestMailGenerator,
SqlGenerator, SqlGenerator,
ProductionDiagnosis,
JiraWorklog, JiraWorklog,
MessageSync, MessageSync,
EventConsumerSync, EventConsumerSync,
@@ -101,7 +122,8 @@ export default {
OperationLogs, OperationLogs,
IpUserMappings, IpUserMappings,
ProjectManagement, ProjectManagement,
ScheduledTasks ScheduledTasks,
JenkinsBuilds
}, },
data() { data() {
return { return {
@@ -131,7 +153,7 @@ export default {
} }
}, },
handleMenuChange(menu) { handleMenuChange(menu) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) { if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
this.redirectToDefault(); this.redirectToDefault();
return; return;
} }
@@ -142,7 +164,9 @@ export default {
const titles = { const titles = {
'env': '环境配置管理', 'env': '环境配置管理',
'weekly-report': '生成周报', 'weekly-report': '生成周报',
'test-mail': '生成提测邮件',
'sql-generator': '生成SQL', 'sql-generator': '生成SQL',
'production-diagnosis': '进产诊断',
'worklog': 'JIRA 工时查询', 'worklog': 'JIRA 工时查询',
'message-sync': '消息同步', 'message-sync': '消息同步',
'event-consumer-sync': '事件消费者同步对比', 'event-consumer-sync': '事件消费者同步对比',
@@ -152,7 +176,8 @@ export default {
'logs': '操作日志', 'logs': '操作日志',
'ip-mappings': 'IP 用户映射', 'ip-mappings': 'IP 用户映射',
'projects': '项目配置管理', 'projects': '项目配置管理',
'scheduled-tasks': '定时任务管理' 'scheduled-tasks': '定时任务管理',
'jenkins-builds': 'Jenkins 一键构建'
}; };
this.pageTitle = titles[menu] || '环境配置管理'; this.pageTitle = titles[menu] || '环境配置管理';
@@ -166,8 +191,12 @@ export default {
page = 'env'; page = 'env';
} else if (path === '/sql-generator') { } else if (path === '/sql-generator') {
page = 'sql-generator'; page = 'sql-generator';
} else if (path === '/production-diagnosis') {
page = 'production-diagnosis';
} else if (path === '/weekly-report') { } else if (path === '/weekly-report') {
page = 'weekly-report'; page = 'weekly-report';
} else if (path === '/test-mail') {
page = 'test-mail';
} else if (path === '/worklog') { } else if (path === '/worklog') {
page = 'worklog'; page = 'worklog';
} else if (path === '/message-sync') { } else if (path === '/message-sync') {
@@ -188,9 +217,11 @@ export default {
page = 'projects'; page = 'projects';
} else if (path === '/scheduled-tasks') { } else if (path === '/scheduled-tasks') {
page = 'scheduled-tasks'; page = 'scheduled-tasks';
} else if (path === '/jenkins-builds') {
page = 'jenkins-builds';
} }
if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks') && !this.isAdmin) { if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks' || page === 'jenkins-builds') && !this.isAdmin) {
this.redirectToDefault(); this.redirectToDefault();
return; return;
} }
+82 -2
View File
@@ -72,6 +72,31 @@
定时任务 定时任务
</a> </a>
<a
href="#"
@click.prevent="setActiveMenu('jenkins-builds')"
v-if="isAdmin"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'jenkins-builds'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'jenkins-builds' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
Jenkins 构建
</a>
<a <a
href="#" href="#"
@click.prevent="setActiveMenu('env')" @click.prevent="setActiveMenu('env')"
@@ -120,6 +145,31 @@
生成SQL 生成SQL
</a> </a>
<a
href="#"
@click.prevent="setActiveMenu('production-diagnosis')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'production-diagnosis'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'production-diagnosis' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6M5 5h14a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01"/>
</svg>
进产诊断
</a>
<!-- JIRA 相关菜单项 --> <!-- JIRA 相关菜单项 -->
<a <a
@@ -146,6 +196,30 @@
生成周报 生成周报
</a> </a>
<a
href="#"
@click.prevent="setActiveMenu('test-mail')"
:class="[
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
activeMenu === 'test-mail'
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
]"
>
<svg
:class="[
'mr-3 h-5 w-5 transition-colors duration-200',
activeMenu === 'test-mail' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
生成提测邮件
</a>
<a <a
href="#" href="#"
@click.prevent="setActiveMenu('worklog')" @click.prevent="setActiveMenu('worklog')"
@@ -398,7 +472,7 @@ export default {
}, },
methods: { methods: {
setActiveMenu(menu) { setActiveMenu(menu) {
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) { if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
return; return;
} }
@@ -417,8 +491,12 @@ export default {
menu = 'env'; menu = 'env';
} else if (path === '/sql-generator') { } else if (path === '/sql-generator') {
menu = 'sql-generator'; menu = 'sql-generator';
} else if (path === '/production-diagnosis') {
menu = 'production-diagnosis';
} else if (path === '/weekly-report') { } else if (path === '/weekly-report') {
menu = 'weekly-report'; menu = 'weekly-report';
} else if (path === '/test-mail') {
menu = 'test-mail';
} else if (path === '/worklog') { } else if (path === '/worklog') {
menu = 'worklog'; menu = 'worklog';
} else if (path === '/message-sync') { } else if (path === '/message-sync') {
@@ -439,9 +517,11 @@ export default {
menu = 'projects'; menu = 'projects';
} else if (path === '/scheduled-tasks') { } else if (path === '/scheduled-tasks') {
menu = 'scheduled-tasks'; menu = 'scheduled-tasks';
} else if (path === '/jenkins-builds') {
menu = 'jenkins-builds';
} }
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) { if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) {
menu = 'env'; menu = 'env';
} }
@@ -0,0 +1,940 @@
<template>
<div class="space-y-2">
<div class="flex items-center justify-between bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
<div>
<h3 class="text-base font-bold text-gray-800">Jenkins 一键构建</h3>
<p class="text-xs text-gray-500">勾选 Jenkins 通知项目调整参数后批量触发 Build</p>
</div>
<div class="flex items-center gap-2">
<div
v-if="refreshing"
class="inline-flex items-center gap-1.5 rounded border border-blue-100 bg-blue-50 px-2.5 py-1.5 text-xs text-blue-700"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 animate-spin">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
正在刷新
</div>
<button
@click="refreshProjects()"
:disabled="refreshing || triggering"
class="px-2.5 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded hover:bg-gray-200 disabled:opacity-50 flex items-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': refreshing}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
刷新
</button>
<button
@click="triggerBuilds"
:disabled="triggering || selectedProjects.length === 0"
class="px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
<svg v-if="!triggering" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.3 2.84A1.5 1.5 0 004 4.11v11.78a1.5 1.5 0 002.3 1.27l9.34-5.89a1.5 1.5 0 000-2.54L6.3 2.84z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 animate-spin">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39z" clip-rule="evenodd" />
</svg>
{{ triggering ? '触发中...' : `触发选中 (${selectedProjects.length})` }}
</button>
</div>
</div>
<div v-if="message" class="text-xs text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">{{ message }}</div>
<div v-if="error" class="text-xs text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">{{ error }}</div>
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_24rem] gap-2 items-start">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-gray-700 text-sm">可构建项目</h4>
<span class="text-xs text-gray-400">{{ projects.length }} 个项目</span>
</div>
<label v-if="projects.length > 0" class="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" :checked="isAllSelected" @change="toggleAll" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
全选
</label>
</div>
<div v-if="loading" class="p-10 text-center text-sm text-gray-400">正在后台同步 Jenkins 项目页面不会被阻塞...</div>
<div v-else-if="projects.length === 0" class="p-10 text-center text-sm text-gray-400">
暂无启用 Jenkins 发布通知且配置 Job 名称的项目
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100 table-fixed">
<thead class="bg-gray-50">
<tr>
<th class="w-60 px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">项目 / Job</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Build 参数</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr v-for="project in projects" :key="project.slug" class="hover:bg-gray-50 align-top">
<td class="px-4 py-2">
<label class="flex items-start gap-2 cursor-pointer">
<input v-model="project.selected" type="checkbox" class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" @change="savePreferences" />
<span class="min-w-0 leading-tight">
<span class="flex items-center gap-1.5 min-w-0">
<span class="font-mono text-sm font-semibold text-gray-800 truncate">{{ project.slug }}</span>
<span class="text-[11px] text-gray-500 truncate" :title="project.name">{{ project.name }}</span>
</span>
<span class="block font-mono text-[11px] text-gray-400 truncate" :title="project.jenkins_job_name">{{ project.jenkins_job_name }}</span>
</span>
</label>
</td>
<td class="px-4 py-2">
<div v-if="project.parameters.length === 0" class="text-xs text-gray-400 leading-7">
无参数直接触发 build
</div>
<div v-else class="space-y-2">
<div class="grid grid-cols-[7rem_9rem_5.5rem_8rem] gap-2 items-start">
<template v-for="parameterName in primaryParameterOrder" :key="parameterName">
<parameter-control
v-if="parameterByName(project, parameterName)"
:project="project"
:parameter="parameterByName(project, parameterName)"
:triggering="triggering"
class="w-full"
@save="savePreferences"
@toggle-multi="toggleMultiValue"
/>
<div v-else class="h-8"></div>
</template>
</div>
<div v-if="hasSecondaryParameters(project)" class="flex flex-wrap gap-2 border-t border-gray-100 pt-2">
<div class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-start w-full">
<parameter-control
v-if="parameterByName(project, 'region')"
:project="project"
:parameter="parameterByName(project, 'region')"
:triggering="triggering"
class="w-full"
@save="savePreferences"
@toggle-multi="toggleMultiValue"
/>
<div v-else class="min-h-[4.25rem]"></div>
<parameter-control
v-if="parameterByName(project, 'project')"
:project="project"
:parameter="parameterByName(project, 'project')"
:triggering="triggering"
class="w-full"
@save="savePreferences"
@toggle-multi="toggleMultiValue"
/>
<div v-else class="min-h-[4.25rem]"></div>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<aside class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-3 py-2 border-b border-gray-200 flex items-center justify-between">
<h4 class="font-semibold text-gray-700 text-sm">发布记录</h4>
<span class="text-xs text-gray-400">{{ operationRecords.length }}</span>
</div>
<div v-if="operationRecords.length === 0" class="p-4 text-xs text-gray-400">
暂无发布记录
</div>
<div v-else class="divide-y divide-gray-100 max-h-[calc(100vh-13rem)] overflow-y-auto">
<div v-for="record in operationRecords" :key="record.id" class="px-3 py-2">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-[11px] text-gray-500">{{ record.time }}</span>
<div class="flex items-center gap-1.5">
<span class="rounded px-1.5 py-0.5 text-[11px]" :class="statusBadgeClass(record.status)">
{{ statusLabel(record.status) }}
</span>
<button
@click="rebuildOperationRecord(record)"
:disabled="record.rebuilding || !record.project_slug || triggering"
class="rounded border border-blue-200 px-1.5 py-0.5 text-[11px] text-blue-600 hover:bg-blue-50 disabled:opacity-50"
>
{{ record.rebuilding ? 'rebuild中' : 'rebuild' }}
</button>
<button
v-if="canCancelRecord(record)"
@click="cancelOperationRecord(record)"
:disabled="record.cancelling"
class="rounded border border-red-200 px-1.5 py-0.5 text-[11px] text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{{ record.cancelling ? '取消中' : '取消' }}
</button>
</div>
</div>
<div class="mt-1 flex items-center gap-1.5 text-xs text-gray-700 min-w-0">
<span class="font-mono font-semibold truncate" :title="record.project_slug">{{ record.project_slug || '-' }}</span>
<span class="text-[11px] text-gray-400 truncate" :title="record.project_name">{{ record.project_name }}</span>
</div>
<div class="mt-0.5 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-1 text-[11px] text-gray-500">
<span class="text-gray-400">project</span>
<span class="font-mono truncate" :title="record.project_parameter">{{ record.project_parameter || '-' }}</span>
<span class="text-gray-400">构建号</span>
<span class="font-mono truncate">{{ record.build_number ? `#${record.build_number}` : '-' }}</span>
</div>
<div v-if="record.message" class="mt-1 text-[11px] text-gray-400 truncate" :title="record.message">
{{ record.message }}
</div>
</div>
</div>
</aside>
</div>
</div>
</template>
<script>
let cachedBuildProjects = null;
const ParameterControl = {
name: 'ParameterControl',
props: {
project: { type: Object, required: true },
parameter: { type: Object, required: true },
triggering: { type: Boolean, default: false }
},
emits: ['save', 'toggle-multi'],
methods: {
normalizedChoices(parameter) {
return (parameter.choices || []).map((choice) => {
if (choice && typeof choice === 'object') {
return {
value: choice.value,
label: choice.label || choice.value
};
}
return {
value: choice,
label: choice
};
});
},
isBooleanParameter(parameter) {
return String(parameter.type || '').toLowerCase().includes('boolean');
},
isMultiSelected(project, parameter, value) {
return (project.values[parameter.name] || []).includes(value);
},
formatDefault(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
}
},
template: `
<label
class="inline-flex items-center min-h-8 max-w-full rounded border border-gray-200 bg-white overflow-hidden"
:class="{ 'min-h-[4.25rem]': parameter.name === 'project' }"
:title="parameter.description || parameter.name"
>
<span class="self-stretch px-2 text-[11px] font-medium text-gray-500 bg-gray-50 border-r border-gray-200 inline-flex items-center whitespace-nowrap">
{{ parameter.name }}
</span>
<select
v-if="!parameter.multiple && parameter.choices && parameter.choices.length > 0"
v-model="project.values[parameter.name]"
:disabled="triggering"
class="h-8 min-w-20 max-w-32 px-2 text-xs border-0 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
@change="$emit('save')"
>
<option v-for="choice in normalizedChoices(parameter)" :key="choice.value" :value="choice.value">{{ choice.label }}</option>
</select>
<span v-else-if="parameter.multiple" class="px-2 py-1 grid grid-rows-2 grid-flow-col auto-cols-max gap-1.5 min-h-[4.25rem] max-w-full content-start overflow-x-auto">
<label
v-for="choice in normalizedChoices(parameter)"
:key="choice.value"
class="inline-flex items-center gap-1 rounded bg-gray-50 px-1.5 py-0.5 text-xs text-gray-700 border border-gray-200 cursor-pointer hover:bg-blue-50 hover:border-blue-200"
>
<input
:checked="isMultiSelected(project, parameter, choice.value)"
type="checkbox"
:disabled="triggering"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
@change="$emit('toggle-multi', project, parameter.name, choice.value)"
/>
{{ choice.label }}
</label>
</span>
<span v-else-if="isBooleanParameter(parameter)" class="px-2 inline-flex items-center gap-1 text-xs text-gray-700">
<input
v-model="project.values[parameter.name]"
type="checkbox"
:disabled="triggering"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 disabled:opacity-50"
@change="$emit('save')"
/>
</span>
<input
v-else
v-model="project.values[parameter.name]"
type="text"
:disabled="triggering"
class="h-8 w-28 px-2 text-xs border-0 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
:placeholder="formatDefault(parameter.default)"
@input="$emit('save')"
/>
</label>
`
};
export default {
name: 'JenkinsBuilds',
components: {
ParameterControl
},
preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1',
cacheKey: 'toolbox.jenkinsBuilds.projectsCache.v1',
operationRecordsKey: 'toolbox.jenkinsBuilds.operationRecords.v1',
hiddenParameterNames: ['sql', 'masterCheck'],
data() {
return {
loading: false,
refreshing: false,
triggering: false,
statusChecking: false,
statusPollingTimer: null,
projects: [],
operationRecords: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'deployVersion'],
message: '',
error: ''
};
},
computed: {
selectedProjects() {
return this.projects.filter((project) => project.selected);
},
isAllSelected() {
return this.projects.length > 0 && this.selectedProjects.length === this.projects.length;
},
runningOperationRecords() {
return this.operationRecords.filter((record) => this.isRunningStatus(record.status));
}
},
async mounted() {
this.operationRecords = this.loadOperationRecords();
this.loadProjects();
this.ensureStatusPolling();
this.checkOperationStatuses();
},
beforeUnmount() {
this.stopStatusPolling();
},
methods: {
loadProjects() {
this.error = '';
this.message = '';
const freshCachedProjects = this.loadProjectsCache();
if (freshCachedProjects) {
cachedBuildProjects = freshCachedProjects;
this.applyProjects(cachedBuildProjects);
return;
}
if (!cachedBuildProjects) {
cachedBuildProjects = this.loadProjectsCache({ allowExpired: true });
}
if (cachedBuildProjects) {
this.applyProjects(cachedBuildProjects);
} else {
this.loading = true;
}
this.refreshProjects({ silent: Boolean(cachedBuildProjects) });
},
async refreshProjects(options = {}) {
if (this.refreshing) {
return;
}
const { silent = false } = options;
this.refreshing = true;
this.error = '';
if (!silent && this.projects.length === 0) {
this.loading = true;
}
try {
const response = await fetch('/api/admin/jenkins/build-projects', {
headers: { Accept: 'application/json' }
});
const data = await response.json();
if (!response.ok || !data.success) {
this.error = data.message || '加载 Jenkins 项目失败';
return;
}
cachedBuildProjects = data.data.projects || [];
this.saveProjectsCache(cachedBuildProjects);
this.applyProjects(cachedBuildProjects, { preserveCurrentValues: true });
if (!silent) {
this.message = 'Jenkins 项目已刷新';
}
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
this.refreshing = false;
}
},
applyProjects(projects, options = {}) {
const { preserveCurrentValues = false } = options;
const preferences = this.loadPreferences();
const currentProjects = new Map(this.projects.map((project) => [this.preferenceProjectKey(project), project]));
this.projects = projects.map((project) => {
const parameters = this.visibleParameters(project.parameters || []);
const defaults = this.defaultValues(parameters);
const key = this.preferenceProjectKey(project);
const saved = preferences[key] || {};
const current = preserveCurrentValues ? currentProjects.get(key) : null;
return {
...project,
selected: current ? Boolean(current.selected) : Boolean(saved.selected),
parameters,
values: this.mergeSavedValues(defaults, current?.values || saved.values || {}, parameters)
};
});
this.savePreferences();
},
loadProjectsCache(options = {}) {
try {
const { allowExpired = false } = options;
const cache = JSON.parse(window.localStorage.getItem(this.$options.cacheKey) || 'null');
if (!cache || !Array.isArray(cache.projects)) {
return null;
}
if (!allowExpired && cache.date !== this.todayKey()) {
return null;
}
return cache.projects;
} catch (error) {
return null;
}
},
saveProjectsCache(projects) {
window.localStorage.setItem(this.$options.cacheKey, JSON.stringify({
date: this.todayKey(),
projects
}));
},
todayKey() {
const date = new Date();
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
},
defaultValues(parameters) {
return parameters.reduce((values, parameter) => {
if (parameter.multiple) {
values[parameter.name] = Array.isArray(parameter.default) ? parameter.default : [];
} else if (parameter.default !== null && parameter.default !== undefined) {
values[parameter.name] = parameter.default;
} else if (parameter.choices && parameter.choices.length > 0) {
values[parameter.name] = this.normalizedChoices(parameter)[0]?.value || '';
} else if (this.isBooleanParameter(parameter)) {
values[parameter.name] = false;
} else {
values[parameter.name] = '';
}
return values;
}, {});
},
visibleParameters(parameters) {
return parameters.filter((parameter) => !this.$options.hiddenParameterNames.includes(parameter.name));
},
mergeSavedValues(defaults, savedValues, parameters) {
const merged = { ...defaults };
const parameterNames = new Set(parameters.map((parameter) => parameter.name));
Object.entries(savedValues).forEach(([name, value]) => {
if (!parameterNames.has(name)) {
return;
}
merged[name] = Array.isArray(defaults[name]) ? (Array.isArray(value) ? value : []) : value;
});
return merged;
},
loadPreferences() {
try {
return JSON.parse(window.localStorage.getItem(this.$options.preferenceKey) || '{}');
} catch (error) {
return {};
}
},
loadOperationRecords() {
try {
const records = JSON.parse(window.localStorage.getItem(this.$options.operationRecordsKey) || '[]');
return Array.isArray(records) ? records.map((record) => this.normalizeOperationRecord(record)) : [];
} catch (error) {
return [];
}
},
saveOperationRecords() {
window.localStorage.setItem(this.$options.operationRecordsKey, JSON.stringify(this.operationRecords.slice(0, 30)));
},
savePreferences() {
const preferences = this.projects.reduce((payload, project) => {
payload[this.preferenceProjectKey(project)] = {
selected: Boolean(project.selected),
values: project.values || {}
};
return payload;
}, {});
window.localStorage.setItem(this.$options.preferenceKey, JSON.stringify(preferences));
},
preferenceProjectKey(project) {
return project.jenkins_job_name || project.slug;
},
isBooleanParameter(parameter) {
return String(parameter.type || '').toLowerCase().includes('boolean');
},
normalizedChoices(parameter) {
return (parameter.choices || []).map((choice) => {
if (choice && typeof choice === 'object') {
return {
value: choice.value,
label: choice.label || choice.value,
selected: Boolean(choice.selected)
};
}
return {
value: choice,
label: choice,
selected: false
};
});
},
isMultiSelected(project, parameter, value) {
return (project.values[parameter.name] || []).includes(value);
},
hasSecondaryParameters(project) {
return Boolean(this.parameterByName(project, 'region') || this.parameterByName(project, 'project'));
},
parameterByName(project, name) {
return project.parameters.find((parameter) => parameter.name === name);
},
toggleMultiValue(project, parameterName, value) {
const current = project.values[parameterName] || [];
if (current.includes(value)) {
project.values[parameterName] = current.filter((item) => item !== value);
this.savePreferences();
return;
}
project.values[parameterName] = [...current, value];
this.savePreferences();
},
formatDefault(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
},
toggleAll() {
const nextValue = !this.isAllSelected;
this.projects.forEach((project) => {
project.selected = nextValue;
});
this.savePreferences();
},
async triggerBuilds() {
if (this.selectedProjects.length === 0) {
this.error = '请至少选择一个项目';
return;
}
if (!window.confirm(`确认触发 ${this.selectedProjects.length} 个 Jenkins Build 吗?`)) {
return;
}
this.triggering = true;
this.error = '';
this.message = '';
const requestedBuilds = this.selectedProjects.map((project) => ({
project_slug: project.slug,
project_name: project.name,
job_name: project.jenkins_job_name,
parameters: this.serializeBuildParameters(project.values)
}));
try {
const { response, data } = await this.submitBuilds(requestedBuilds);
if (!response.ok || !data.success) {
this.error = data.message || '触发失败';
return;
}
this.message = data.message || '触发成功';
} catch (error) {
this.error = error.message;
} finally {
this.triggering = false;
}
},
async submitBuilds(requestedBuilds) {
const response = await fetch('/api/admin/jenkins/trigger-builds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
builds: requestedBuilds.map((build) => ({
project_slug: build.project_slug,
parameters: build.parameters
}))
})
});
const data = await response.json();
this.addOperationRecords(data.data?.results || [], requestedBuilds);
return { response, data };
},
async rebuildOperationRecord(record) {
if (!record.project_slug) {
this.error = '缺少项目标识,无法 rebuild';
return;
}
if (!window.confirm(`确认 rebuild ${record.project_slug} 吗?`)) {
return;
}
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, rebuilding: true } : item
));
this.saveOperationRecords();
this.error = '';
this.message = '';
try {
const { response, data } = await this.submitBuilds([{
project_slug: record.project_slug,
project_name: record.project_name,
job_name: record.job_name,
parameters: record.parameters || {}
}]);
if (!response.ok || !data.success) {
this.error = data.message || 'rebuild 失败';
return;
}
this.message = data.message || 'rebuild 已触发';
} catch (error) {
this.error = error.message;
} finally {
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, rebuilding: false } : item
));
this.saveOperationRecords();
}
},
serializeBuildParameters(values) {
return Object.entries(values || {}).reduce((payload, [name, value]) => {
if (this.$options.hiddenParameterNames.includes(name)) {
return payload;
}
if (Array.isArray(value)) {
payload[name] = value.filter((item) => item !== null && item !== '').join(',');
} else {
payload[name] = value;
}
return payload;
}, {});
},
addOperationRecords(results, requestedBuilds) {
const resultList = Array.isArray(results) ? results : [];
const requestedBySlug = new Map(requestedBuilds.map((build) => [build.project_slug, build]));
const records = resultList.map((result) => {
const requested = requestedBySlug.get(result.project_slug) || {};
const canTrackBuild = Boolean(result.queue_url || result.build_number);
return {
id: `${Date.now()}-${result.project_slug || Math.random().toString(36).slice(2, 8)}`,
time: this.formatRecordTime(new Date()),
project_slug: result.project_slug || requested.project_slug || '',
project_name: result.project_name || requested.project_name || '',
job_name: result.job_name || requested.job_name || '',
project_parameter: this.formatParameterValue(requested.parameters?.project),
status: result.success ? (canTrackBuild ? 'PENDING' : 'UNKNOWN') : 'FAILURE',
queue_url: result.queue_url || null,
build_number: result.build_number || null,
build_url: null,
parameters: requested.parameters || {},
message: result.success
? (canTrackBuild ? '已提交 Jenkins,等待发布结果' : '已提交 Jenkins,但未返回队列地址,无法自动跟踪或取消')
: (result.message || '触发失败'),
cancelling: false,
rebuilding: false
};
});
this.operationRecords = [
...records,
...this.operationRecords
].slice(0, 30);
this.saveOperationRecords();
this.ensureStatusPolling();
this.checkOperationStatuses();
},
normalizeOperationRecord(record) {
if (record.status) {
return {
...record,
parameters: record.parameters || {},
cancelling: false,
rebuilding: false
};
}
return {
id: record.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
time: record.time || '-',
project_slug: record.projects || '',
project_name: record.projects || '历史发布记录',
job_name: '',
project_parameter: '-',
status: record.failed > 0 ? 'FAILURE' : 'SUCCESS',
queue_url: null,
build_number: null,
build_url: null,
parameters: {},
message: `${record.success || 0} 成功 / ${record.failed || 0} 失败`,
cancelling: false,
rebuilding: false
};
},
async checkOperationStatuses() {
const runningRecords = this.runningOperationRecords.filter((record) => record.queue_url || record.build_number);
if (this.statusChecking || runningRecords.length === 0) {
this.ensureStatusPolling();
return;
}
this.statusChecking = true;
try {
const response = await fetch('/api/admin/jenkins/build-statuses', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
builds: runningRecords.map((record) => ({
id: record.id,
project_slug: record.project_slug,
queue_url: record.queue_url,
build_number: record.build_number
}))
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.error = data.message || '查询 Jenkins 发布状态失败';
return;
}
const statuses = new Map((data.data?.results || []).map((result) => [result.id, result]));
this.operationRecords = this.operationRecords.map((record) => {
const status = statuses.get(record.id);
if (!status) {
return record;
}
const nextStatus = record.status === 'CANCELING' && !status.completed
? 'CANCELING'
: this.normalizeJenkinsStatus(status);
return {
...record,
status: nextStatus,
build_number: status.build_number || record.build_number,
build_url: status.build_url || record.build_url,
message: status.message || null,
cancelling: false
};
});
this.saveOperationRecords();
} catch (error) {
this.error = error.message;
} finally {
this.statusChecking = false;
this.ensureStatusPolling();
}
},
async cancelOperationRecord(record) {
if (!window.confirm(`确认取消 ${record.project_slug} 的 Jenkins 发布吗?`)) {
return;
}
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: true, status: 'CANCELING', message: '正在发送取消请求' } : item
));
this.saveOperationRecords();
try {
const response = await fetch('/api/admin/jenkins/cancel-build', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
project_slug: record.project_slug,
queue_url: record.queue_url,
build_number: record.build_number
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.error = data.message || '取消 Jenkins 发布失败';
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: false, status: record.status, message: data.message || item.message } : item
));
this.saveOperationRecords();
return;
}
const cancelResult = data.data?.result || {};
const nextStatus = cancelResult.cancelled_queue ? 'ABORTED' : 'CANCELING';
const nextMessage = cancelResult.cancelled_queue ? '已取消 Jenkins 队列任务' : '已发送停止请求,等待 Jenkins 确认';
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: false, status: nextStatus, message: nextMessage } : item
));
this.saveOperationRecords();
this.ensureStatusPolling();
if (nextStatus === 'CANCELING') {
this.checkOperationStatuses();
}
} catch (error) {
this.error = error.message;
this.operationRecords = this.operationRecords.map((item) => (
item.id === record.id ? { ...item, cancelling: false, status: record.status, message: error.message } : item
));
this.saveOperationRecords();
}
},
normalizeJenkinsStatus(status) {
if (!status.success && status.status === 'UNKNOWN') {
return 'UNKNOWN';
}
if (status.completed) {
return status.status || status.result || 'UNKNOWN';
}
return status.status === 'PENDING' ? 'PENDING' : 'BUILDING';
},
ensureStatusPolling() {
const hasPollableRecords = this.runningOperationRecords.some((record) => record.queue_url || record.build_number);
if (!hasPollableRecords) {
this.stopStatusPolling();
return;
}
if (!this.statusPollingTimer) {
this.statusPollingTimer = window.setInterval(() => {
this.checkOperationStatuses();
}, 10000);
}
},
stopStatusPolling() {
if (this.statusPollingTimer) {
window.clearInterval(this.statusPollingTimer);
this.statusPollingTimer = null;
}
},
isRunningStatus(status) {
return ['PENDING', 'BUILDING', 'CANCELING'].includes(status);
},
canCancelRecord(record) {
return ['PENDING', 'BUILDING'].includes(record.status)
&& Boolean(record.queue_url || record.build_number)
&& !record.cancelling;
},
statusLabel(status) {
return {
PENDING: '发布中',
BUILDING: '发布中',
CANCELING: '取消中',
SUCCESS: '成功',
FAILURE: '失败',
ABORTED: '已取消',
UNSTABLE: '不稳定',
UNKNOWN: '未知'
}[status] || '未知';
},
statusBadgeClass(status) {
if (['PENDING', 'BUILDING', 'CANCELING'].includes(status)) {
return 'bg-blue-50 text-blue-600';
}
if (status === 'SUCCESS') {
return 'bg-green-50 text-green-600';
}
if (status === 'UNSTABLE') {
return 'bg-yellow-50 text-yellow-700';
}
return 'bg-red-50 text-red-600';
},
formatParameterValue(value) {
if (Array.isArray(value)) {
return value.join(', ');
}
if (value === null || value === undefined || value === '') {
return '-';
}
return String(value);
},
formatRecordTime(date) {
const pad = (value) => String(value).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
}
}
</script>
@@ -181,6 +181,22 @@
<input v-model="form.is_important" type="checkbox" id="is_important" class="rounded border-gray-300 text-yellow-500 focus:ring-yellow-500" /> <input v-model="form.is_important" type="checkbox" id="is_important" class="rounded border-gray-300 text-yellow-500 focus:ring-yellow-500" />
<label for="is_important" class="text-sm text-gray-700">标记为重要项目</label> <label for="is_important" class="text-sm text-gray-700">标记为重要项目</label>
</div> </div>
<!-- Jenkins 配置 -->
<div class="border-t border-gray-200 pt-4 mt-4">
<h5 class="text-sm font-medium text-gray-700 mb-3">Jenkins 发布通知</h5>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Jenkins Job 名称</label>
<input v-model="form.jenkins_job_name" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-be-deploy" />
</div>
<div class="flex items-center gap-2">
<input v-model="form.jenkins_notify_enabled" type="checkbox" id="jenkins_notify" class="rounded border-gray-300 text-orange-500 focus:ring-orange-500" />
<label for="jenkins_notify" class="text-sm text-gray-700">启用 Jenkins 发布通知</label>
</div>
</div>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">日志 App 名称 (逗号分隔)</label> <label class="block text-sm font-medium text-gray-700 mb-1">日志 App 名称 (逗号分隔)</label>
<input v-model="form.log_app_names_text" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-api, portal-worker" /> <input v-model="form.log_app_names_text" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-api, portal-worker" />
@@ -309,6 +325,10 @@ const ProjectCard = {
<span class="text-gray-500">版本:</span> <span class="text-gray-500">版本:</span>
<span class="font-mono text-gray-700">{{ project.git_current_version }}</span> <span class="font-mono text-gray-700">{{ project.git_current_version }}</span>
</div> </div>
<div v-if="project.jenkins_job_name" class="flex items-center gap-2">
<span class="text-gray-500">Jenkins:</span>
<span class="font-mono bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded text-xs">{{ project.jenkins_job_name }}</span>
</div>
<div v-if="project.log_app_names?.length" class="flex items-center gap-2"> <div v-if="project.log_app_names?.length" class="flex items-center gap-2">
<span class="text-gray-500">App:</span> <span class="text-gray-500">App:</span>
<span class="text-gray-700">{{ project.log_app_names.join(', ') }}</span> <span class="text-gray-700">{{ project.log_app_names.join(', ') }}</span>
@@ -333,6 +353,14 @@ const ProjectCard = {
</span> </span>
<span class="text-xs" :class="project.auto_create_release_branch ? 'text-purple-600' : 'text-gray-500'">自动创建分支</span> <span class="text-xs" :class="project.auto_create_release_branch ? 'text-purple-600' : 'text-gray-500'">自动创建分支</span>
</label> </label>
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'jenkins_notify_enabled')">
<span class="relative inline-block">
<input type="checkbox" :checked="project.jenkins_notify_enabled" class="sr-only peer" />
<span class="block w-8 h-4 bg-gray-200 rounded-full peer peer-checked:bg-orange-500 transition-colors"></span>
<span class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></span>
</span>
<span class="text-xs" :class="project.jenkins_notify_enabled ? 'text-orange-600' : 'text-gray-500'">Jenkins通知</span>
</label>
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'is_important')"> <label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'is_important')">
<span class="relative inline-block"> <span class="relative inline-block">
<input type="checkbox" :checked="project.is_important" class="sr-only peer" /> <input type="checkbox" :checked="project.is_important" class="sr-only peer" />
@@ -418,6 +446,8 @@ export default {
git_monitor_enabled: false, git_monitor_enabled: false,
auto_create_release_branch: false, auto_create_release_branch: false,
is_important: false, is_important: false,
jenkins_job_name: '',
jenkins_notify_enabled: false,
log_app_names_text: '', log_app_names_text: '',
log_env: 'production' log_env: 'production'
}; };
@@ -456,6 +486,8 @@ export default {
git_monitor_enabled: project.git_monitor_enabled || false, git_monitor_enabled: project.git_monitor_enabled || false,
auto_create_release_branch: project.auto_create_release_branch || false, auto_create_release_branch: project.auto_create_release_branch || false,
is_important: project.is_important || false, is_important: project.is_important || false,
jenkins_job_name: project.jenkins_job_name || '',
jenkins_notify_enabled: project.jenkins_notify_enabled || false,
log_app_names_text: (project.log_app_names || []).join(', '), log_app_names_text: (project.log_app_names || []).join(', '),
log_env: project.log_env || 'production' log_env: project.log_env || 'production'
}; };
@@ -483,6 +515,8 @@ export default {
git_monitor_enabled: this.form.git_monitor_enabled, git_monitor_enabled: this.form.git_monitor_enabled,
auto_create_release_branch: this.form.auto_create_release_branch, auto_create_release_branch: this.form.auto_create_release_branch,
is_important: this.form.is_important, is_important: this.form.is_important,
jenkins_job_name: this.form.jenkins_job_name || null,
jenkins_notify_enabled: this.form.jenkins_notify_enabled,
log_app_names: this.form.log_app_names_text ? this.form.log_app_names_text.split(',').map(s => s.trim()).filter(Boolean) : null, log_app_names: this.form.log_app_names_text ? this.form.log_app_names_text.split(',').map(s => s.trim()).filter(Boolean) : null,
log_env: this.form.log_env || null log_env: this.form.log_env || null
}; };
@@ -6,7 +6,7 @@
<h3 class="text-lg font-bold text-gray-800">系统设置</h3> <h3 class="text-lg font-bold text-gray-800">系统设置</h3>
<p class="text-sm text-gray-500">管理本地偏好与服务端全局配置</p> <p class="text-sm text-gray-500">管理本地偏好与服务端全局配置</p>
</div> </div>
<div v-if="jira.loading || configs.loading" class="text-sm text-blue-600 animate-pulse"> <div v-if="jira.loading || configs.loading || erpRequestReport.loading" class="text-sm text-blue-600 animate-pulse">
数据同步中... 数据同步中...
</div> </div>
</div> </div>
@@ -86,6 +86,46 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="isAdmin" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h4 class="font-semibold text-gray-700 text-sm">ERP 请求日报</h4>
</div>
<div class="p-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">钉钉机器人 Token</label>
<input
v-model="erpRequestReport.dingtalkToken"
type="password"
autocomplete="new-password"
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入新的 access_token"
/>
<p class="mt-1 text-xs text-gray-400">Token 仅用于保存不会在页面中回显保存后需到定时任务启用 ERP 请求日报</p>
</div>
<div class="flex items-center justify-between gap-3">
<span :class="erpRequestReport.configured ? 'text-green-600' : 'text-yellow-600'" class="text-xs">
{{ erpRequestReport.configured ? '已配置 Token' : '未配置 Token' }}
</span>
<button
@click="saveErpRequestReportConfig"
:disabled="erpRequestReport.saving || !erpRequestReport.dingtalkToken.trim()"
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{{ erpRequestReport.saving ? '保存中...' : '保存 Token' }}
</button>
</div>
<div v-if="erpRequestReport.message" class="text-sm text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">
{{ erpRequestReport.message }}
</div>
<div v-if="erpRequestReport.error" class="text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">
{{ erpRequestReport.error }}
</div>
</div>
</div>
</div> </div>
<!-- Right Column: Database Configs --> <!-- Right Column: Database Configs -->
@@ -276,6 +316,15 @@ export default {
description: '', description: '',
valueText: '' valueText: ''
} }
},
erpRequestReport: {
loading: false,
saving: false,
loadedOnce: false,
configured: false,
dingtalkToken: '',
message: '',
error: ''
} }
}; };
}, },
@@ -290,14 +339,19 @@ export default {
this.jira.localDefaultQueryUserSaved = savedOverride; this.jira.localDefaultQueryUserSaved = savedOverride;
await this.loadServerConfig(); await this.loadServerConfig();
if (this.isAdmin) { if (this.isAdmin) {
await this.loadConfigs(); await Promise.all([this.loadConfigs(), this.loadErpRequestReportConfig()]);
} }
}, },
watch: { watch: {
isAdmin(value) { isAdmin(value) {
if (value && !this.configs.loadedOnce) { if (value) {
if (!this.configs.loadedOnce) {
this.loadConfigs(); this.loadConfigs();
} }
if (!this.erpRequestReport.loadedOnce) {
this.loadErpRequestReportConfig();
}
}
} }
}, },
methods: { methods: {
@@ -397,6 +451,69 @@ export default {
this.configs.loading = false; this.configs.loading = false;
} }
}, },
async loadErpRequestReportConfig() {
if (!this.isAdmin) {
return;
}
this.erpRequestReport.loading = true;
this.erpRequestReport.error = '';
try {
const response = await fetch('/api/admin/erp-request-report/config', {
headers: { Accept: 'application/json' }
});
const data = await this.parseJsonResponse(response);
if (!response.ok || !data.success) {
this.erpRequestReport.error = this.getErrorMessage(data, '加载 ERP 请求日报配置失败');
return;
}
this.erpRequestReport.configured = Boolean(data.data.dingtalk_token_configured);
this.erpRequestReport.loadedOnce = true;
} catch (error) {
this.erpRequestReport.error = error.message;
} finally {
this.erpRequestReport.loading = false;
}
},
async saveErpRequestReportConfig() {
if (!this.erpRequestReport.dingtalkToken.trim()) {
return;
}
this.erpRequestReport.saving = true;
this.erpRequestReport.error = '';
this.erpRequestReport.message = '';
try {
const response = await fetch('/api/admin/erp-request-report/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
dingtalk_token: this.erpRequestReport.dingtalkToken.trim()
})
});
const data = await this.parseJsonResponse(response);
if (!response.ok || !data.success) {
this.erpRequestReport.error = this.getErrorMessage(data, '保存 ERP 请求日报配置失败');
return;
}
this.erpRequestReport.configured = Boolean(data.data.dingtalk_token_configured);
this.erpRequestReport.dingtalkToken = '';
this.erpRequestReport.message = data.message || 'Token 已保存';
} catch (error) {
this.erpRequestReport.error = error.message;
} finally {
this.erpRequestReport.saving = false;
}
},
async createConfig() { async createConfig() {
if (!this.configs.newConfig.key.trim()) { if (!this.configs.newConfig.key.trim()) {
this.configs.error = 'key 不能为空'; this.configs.error = 'key 不能为空';
+49 -7
View File
@@ -87,6 +87,18 @@
> >
查询今天数据 查询今天数据
</button> </button>
<button
@click="setQuickDateRange('previousDay')"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
前一天
</button>
<button
@click="setQuickDateRange('nextDay')"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
后一天
</button>
<button <button
@click="clearQuickSelect()" @click="clearQuickSelect()"
v-if="workLogs.activeQuickSelect" v-if="workLogs.activeQuickSelect"
@@ -378,8 +390,8 @@ export default {
const monday = new Date(today); const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset); monday.setDate(today.getDate() + mondayOffset);
this.workLogs.startDate = monday.toISOString().split('T')[0]; this.workLogs.startDate = this.formatDate(monday);
this.workLogs.endDate = today.toISOString().split('T')[0]; this.workLogs.endDate = this.formatDate(today);
}, },
setLastWeekDateRange() { setLastWeekDateRange() {
@@ -394,28 +406,50 @@ export default {
const lastSunday = new Date(today); const lastSunday = new Date(today);
lastSunday.setDate(today.getDate() + lastSundayOffset); lastSunday.setDate(today.getDate() + lastSundayOffset);
this.workLogs.startDate = lastMonday.toISOString().split('T')[0]; this.workLogs.startDate = this.formatDate(lastMonday);
this.workLogs.endDate = lastSunday.toISOString().split('T')[0]; this.workLogs.endDate = this.formatDate(lastSunday);
}, },
setYesterdayDateRange() { setYesterdayDateRange() {
const yesterday = new Date(); const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T')[0]; const dateStr = this.formatDate(yesterday);
this.workLogs.startDate = dateStr; this.workLogs.startDate = dateStr;
this.workLogs.endDate = dateStr; this.workLogs.endDate = dateStr;
}, },
setTodayDateRange() { setTodayDateRange() {
const today = new Date(); const today = new Date();
const dateStr = today.toISOString().split('T')[0]; const dateStr = this.formatDate(today);
this.workLogs.startDate = dateStr; this.workLogs.startDate = dateStr;
this.workLogs.endDate = dateStr; this.workLogs.endDate = dateStr;
}, },
setQuickDateRange(type) { shiftDateRange(days) {
const startDate = new Date(`${this.workLogs.startDate}T00:00:00`);
const endDate = new Date(`${this.workLogs.endDate}T00:00:00`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
this.setTodayDateRange();
return;
}
startDate.setDate(startDate.getDate() + days);
endDate.setDate(endDate.getDate() + days);
this.workLogs.startDate = this.formatDate(startDate);
this.workLogs.endDate = this.formatDate(endDate);
},
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
async setQuickDateRange(type) {
this.workLogs.activeQuickSelect = type; this.workLogs.activeQuickSelect = type;
switch (type) { switch (type) {
@@ -428,7 +462,15 @@ export default {
case 'today': case 'today':
this.setTodayDateRange(); this.setTodayDateRange();
break; break;
case 'previousDay':
this.shiftDateRange(-1);
break;
case 'nextDay':
this.shiftDateRange(1);
break;
} }
await this.getWorkLogs();
}, },
clearQuickSelect() { clearQuickSelect() {
@@ -0,0 +1,259 @@
<template>
<div class="h-full overflow-y-auto bg-gray-50">
<div class="sticky top-0 z-20 border-b border-gray-200 bg-white/95 backdrop-blur">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-bold text-gray-900">生成提测邮件</h1>
<p class="text-xs text-gray-500">动线Sprint 收件人 Jira需求 容器/数据库 八截图与九/十表格 预览下载</p>
</div>
<div class="flex gap-2">
<button @click="loadData" :disabled="loading" class="btn-primary">{{ loading ? '拉取中...' : '刷新 Jira' }}</button>
<button v-if="isAdmin" @click="openMailDraft" :disabled="draftOpening" class="btn-secondary">{{ draftOpening ? '打开中...' : '打开邮件草稿' }}</button>
<button @click="downloadEml" :disabled="downloading" class="btn-success">{{ downloading ? '生成中...' : '下载 .eml' }}</button>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 md:grid-cols-6">
<div v-for="step in steps" :key="step" class="rounded bg-blue-50 px-2 py-1 text-blue-700">{{ step }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 p-4 2xl:grid-cols-[minmax(0,1fr)_520px]">
<main class="space-y-4">
<section class="dense-card">
<div class="dense-title">1. Sprint 与邮件基础信息</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-12">
<label class="lg:col-span-3 compact-field">
<span>Sprint下拉可选</span>
<select v-model="sprint" @change="handleSprintSelection" class="control">
<option value="">请选择 Sprint</option>
<option v-for="option in sprintOptions" :key="option.id || option.name" :value="option.id || option.name">{{ option.label || option.name || option.id }}</option>
</select>
</label>
<label class="lg:col-span-2 compact-field"><span>手工 Sprint ID</span><input v-model="sprint" class="control" placeholder="如 2324"></label>
<label class="lg:col-span-7 compact-field"><span>邮件主题</span><input v-model="subject" class="control"></label>
</div>
<div v-if="error" class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
<div v-if="jql" class="mt-2 truncate text-xs text-gray-400" :title="jql">JQL{{ jql }}</div>
</section>
<section class="dense-card">
<div class="dense-title">2. 收件人</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-12">
<label class="xl:col-span-3 compact-field"><span>From</span><input v-model="from" class="control"></label>
<label class="xl:col-span-4 compact-field"><span>To</span><textarea v-model="to" rows="3" class="control"></textarea></label>
<label class="xl:col-span-5 compact-field"><span>Cc</span><textarea v-model="cc" rows="3" class="control"></textarea></label>
</div>
</section>
<section class="dense-card">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="dense-title !mb-0">3. Jira 需求表格自动生成{{ issues.length }} </div>
<div class="text-xs text-gray-500">邮件一需求内容直接使用此表格已去掉第一点截图入口</div>
</div>
<div class="max-h-[360px] overflow-auto rounded border">
<table class="dense-table min-w-[1180px]">
<thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th><th class="w-14">操作</th></tr></thead>
<tbody>
<tr v-for="issue in issues" :key="issue.key">
<td><a :href="issue.url" target="_blank" class="text-blue-600">{{ issue.key }}</a></td>
<td class="min-w-72">{{ issue.summary }}</td>
<td>{{ issue.reporter || '-' }}</td><td>{{ issue.status }}</td><td>{{ issue.developer || '-' }}</td><td>{{ issue.assignee || '-' }}</td><td>{{ issue.sprint || '-' }}</td><td>{{ issue.estimated_test_at || '' }}</td><td>{{ issue.estimated_release_at || '' }}</td>
<td><button @click="removeIssue(issue.key)" class="text-xs text-red-600 hover:underline">删除</button></td>
</tr>
<tr v-if="!issues.length"><td colspan="10" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr>
</tbody>
</table>
</div>
</section>
<section class="dense-card">
<div class="dense-title">4. 容器部署和版本 / 数据库自动探测</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-3">
<div v-for="group in containerGroups" :key="group.key" class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<label class="text-sm font-semibold text-gray-800"><input type="checkbox" :checked="isGroupSelected(group.key)" @change="toggleGroup(group.key, $event.target.checked)"> {{ group.label }}</label>
<input v-model="versions[group.key]" @change="refreshDatabases" class="w-28 rounded border px-2 py-1 text-xs" placeholder="版本号">
</div>
<div class="space-y-1">
<label v-for="container in group.containers" :key="container.name" class="flex items-center justify-between gap-2 text-xs text-gray-700">
<span><input type="checkbox" :value="container.name" v-model="selectedContainers"> {{ container.name }}</span>
<span class="text-gray-400">{{ container.location }}</span>
</label>
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<button @click="refreshDatabases" class="rounded bg-blue-100 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-200">刷新数据库分支</button>
<span class="text-xs text-gray-500">agent / portal / portal-ticket / mono 版本号可各自调整</span>
</div>
<div class="mt-3 overflow-auto rounded border">
<table class="dense-table min-w-[520px]"><thead><tr><th>系统</th><th>是否有数据库</th><th>分支</th></tr></thead><tbody><tr v-for="row in databases" :key="row.group"><td>{{ row.system }}</td><td>{{ row.has_database }}</td><td>{{ row.branch || '-' }}</td></tr></tbody></table>
</div>
</section>
<section class="dense-card">
<div class="dense-title">5. 可编辑补充内容</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-12">
<label class="lg:col-span-2 compact-field"><span>冒烟通过率</span><input v-model="smokeRate" class="control"></label>
<label class="lg:col-span-5 compact-field"><span>冒烟链接</span><textarea v-model="smokeUrl" rows="2" class="control" placeholder="支持多行,每行一个链接或说明"></textarea></label>
<label class="lg:col-span-5 compact-field"><span>技术文档</span><input v-model="techDocs" class="control" placeholder="链接或说明"></label>
<label class="lg:col-span-6 compact-field"><span>紧急需求</span><textarea v-model="urgentItems" rows="2" class="control"></textarea></label>
<label class="lg:col-span-6 compact-field"><span>延期需求</span><textarea v-model="delayedItems" rows="2" class="control"></textarea></label>
</div>
</section>
<section class="dense-card">
<div class="mb-2 flex items-center justify-between"><div class="dense-title !mb-0">环境部署准备清单截图</div><button @click="clearEnvironmentScreenshots" class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">清空截图</button></div>
<div ref="environmentPasteBox" contenteditable="true" @paste="handlePaste($event, 'environment')" class="min-h-28 rounded-lg border-2 border-dashed border-green-300 bg-green-50 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
<p class="text-gray-500">点击这里后直接粘贴环境部署准备清单截图当前 {{ environmentImages.length }} </p>
</div>
</section>
<section class="dense-card">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<div class="dense-title !mb-0">测试注意事项 / 其他依赖项表格填写</div>
<div class="flex items-center gap-2">
<span v-if="draftSource" class="text-xs text-gray-400">草稿来源{{ draftSourceLabel }}</span>
<button @click="generateDraftSections" :disabled="draftLoading" class="rounded bg-green-100 px-2 py-1 text-xs text-green-700 hover:bg-green-200 disabled:opacity-50">{{ draftLoading ? '生成中...' : '生成九/十草稿' }}</button>
<button @click="addNoteRow" class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200">新增一行</button>
</div>
</div>
<EditableDenseTable :headers="noteHeaders" :rows="testNoteRows" :columns="noteColumns" @remove="removeNoteRow" />
</section>
<section class="dense-card">
<div class="mb-2 flex items-center justify-between gap-2"><div class="dense-title !mb-0">已知问题与风险表格填写</div><button @click="addRiskRow" class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200">新增一行</button></div>
<EditableDenseTable :headers="riskHeaders" :rows="riskRows" :columns="riskColumns" @remove="removeRiskRow" />
</section>
</main>
<aside class="2xl:sticky 2xl:top-[104px] 2xl:h-[calc(100vh-120px)]">
<section class="dense-card flex h-full flex-col">
<div class="mb-3 flex items-center justify-between"><div class="dense-title !mb-0">邮件预览</div><span class="text-xs text-gray-500">Thunderbird 打开前快速校验</span></div>
<div class="min-h-0 flex-1 overflow-auto rounded-md border bg-white p-4" v-html="mailHtml"></div>
</section>
</aside>
</div>
</div>
</template>
<script>
function normalizeTestMailSprintPeriod(value) {
const text = String(value || '').trim();
if (!text) return '';
let match = text.match(/[A-Z]+(\d{4})(中|底)迭代/iu);
if (match) return `Sprint${match[1]}${match[2]}`;
match = text.match(/(?:Sprint\s*)?(\d{4})\s*月\s*(中|底)/iu);
if (match) return `Sprint${match[1]}${match[2]}`;
match = text.match(/(?:20)?(\d{2})\s*年\s*0?([1-9]|1[0-2])\s*月\s*(中|底)/u);
if (match) return `Sprint${match[1]}${String(Number(match[2])).padStart(2, '0')}${match[3]}`;
return '';
}
const LAST_SPRINT_STORAGE_KEY = 'toolbox.testMail.lastSprint';
const RECIPIENTS_STORAGE_KEY = 'toolbox.testMail.recipients';
const EditableDenseTable = {
props: ['headers', 'rows', 'columns'], emits: ['remove'],
template: `<div class="overflow-auto rounded border"><table class="dense-table min-w-[900px]"><thead><tr><th v-for="h in headers" :key="h">{{ h }}</th><th class="w-14">操作</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row._id || index"><td v-for="col in columns" :key="col.key"><select v-if="col.type === 'select'" v-model="row[col.key]" class="table-control"><option v-for="option in col.options" :key="option" :value="option">{{ option }}</option></select><textarea v-else-if="col.type === 'textarea'" v-model="row[col.key]" rows="2" class="table-control resize-y"></textarea><input v-else v-model="row[col.key]" class="table-control"></td><td><button @click="$emit('remove', index)" class="text-xs text-red-600 hover:underline">删除</button></td></tr></tbody></table></div>`
};
export default {
name: 'TestMailGenerator', components: { EditableDenseTable },
props: {
isAdmin: {
type: Boolean,
default: false,
},
},
data() { return {
steps: ['1 Sprint','2 收件人','3 Jira 表格','4 容器/数据库','5 八截图+九/十表格','6 下载'],
sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftRequestId: 0, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [],
from: '万文山 <wanwenshan@angelalign.com>',
to: '"ouyangxiaowen@angelalign.com" <ouyangxiaowen@angelalign.com>, "yaowenying@angelalign.com" <yaowenying@angelalign.com>, "guoziliang@angelalign.com" <guoziliang@angelalign.com>, chenhui7@angelalign.com, leyunpeng@angelalign.com',
cc: '黄宇 <huangyu@angelalign.com>, "yujie2@angelalign.com" <yujie2@angelalign.com>, "lizhongyuan@angelalign.com" <lizhongyuan@angelalign.com>, "huangfang2@angelalign.com" <huangfang2@angelalign.com>, 周国辉 <zhouguohui@angelalign.com>, "yuxinli@angelalign.com" <yuxinli@angelalign.com>, "zhangzhen3@angelalign.com" <zhangzhen3@angelalign.com>, "renzhaochun@angelalign.com" <renzhaochun@angelalign.com>, "yangyunhao@angelalign.com" <yangyunhao@angelalign.com>, "xiangshang@angelalign.com" <xiangshang@angelalign.com>, "liuyuan1@angelalign.com" <liuyuan1@angelalign.com>, zhangyuan1@angelalign.com, yangjuan1@angelalign.com, wanghe2@angelalign.com',
subject: '【提测】需求提测(SP、PP、TP)', smokeRate: '100%', smokeUrl: '', techDocs: '无', urgentItems: '无', delayedItems: '无',
selectedContainers: [], selectedGroups: [], versions: {}, databases: [],
testNoteRows: [{_id:1,type:'脚本',issue:'',system:'',content:'',owner:''},{_id:2,type:'配置项',issue:'',system:'',content:'',owner:''}],
riskRows: [{_id:1,problem:'',impact:'',action:'',owner:''}],
}; },
computed: {
issueHeaders() { return ['关键字','主题','报告人','状态','研发owner','经办人','Sprint','预计提测时间','预计发布时间']; },
noteHeaders() { return ['类型','需求/事项','系统/容器','内容(脚本、配置项、依赖说明)','负责人']; },
noteColumns() { return [{key:'type',type:'select',options:['脚本','配置项','其他依赖项','测试注意事项']},{key:'issue'},{key:'system'},{key:'content',type:'textarea'},{key:'owner'}]; },
riskHeaders() { return ['已知问题/风险','影响范围','处理方案/规避措施','负责人']; },
riskColumns() { return [{key:'problem',type:'textarea'},{key:'impact',type:'textarea'},{key:'action',type:'textarea'},{key:'owner'}]; },
containerGroups() { const groups = this.defaults.container_groups || {}; return Object.keys(groups).map(key => ({ key, ...groups[key] })); },
environmentImages() { return this.images.filter(i => i.section === 'environment'); },
selectedContainerRows() { const rows=[]; for (const group of this.containerGroups) for (const c of group.containers || []) if (this.selectedContainers.includes(c.name)) rows.push({name:c.name, version:this.versions[group.key] || group.default_version || '', location:c.location}); return rows; },
draftSourceLabel() { return this.draftSource === 'ai' ? 'AI' : '规则默认'; },
mailHtml() { return `<div style="font-family:'Microsoft YaHei UI',Arial,sans-serif;font-size:14px;color:#000;line-height:1.5">${this.section('一、需求内容')}${this.issueTableHtml()}${this.section('二、技术文档')}${this.multiline(this.techDocs)}${this.section('三、冒烟测试情况:')}${this.paragraph(`冒烟通过率:${this.escape(this.smokeRate)}`)}${this.smokeLinksHtml()}${this.section('四、计划异常情况')}${this.paragraph('紧急需求:<br>&nbsp;&nbsp;&nbsp;&nbsp;' + this.escape(this.urgentItems).replace(/\n/g, '<br>&nbsp;&nbsp;&nbsp;&nbsp;'))}${this.paragraph('延期需求:<br>&nbsp;&nbsp;&nbsp;&nbsp;' + this.escape(this.delayedItems).replace(/\n/g, '<br>&nbsp;&nbsp;&nbsp;&nbsp;'))}${this.section('五、容器部署和版本')}${this.simpleTable(['容器','版本号','服务器所在地'], this.selectedContainerRows, ['name','version','location'])}${this.section('六、数据库')}${this.simpleTable(['系统','是否有数据库','分支'], this.databases, ['system','has_database','branch'])}${this.section('七、是否涉及合规')}${this.paragraph('&nbsp;&nbsp;&nbsp;&nbsp;不涉及')}${this.section('八、环境部署准备清单:')}${this.screenshotHtml('environment')}${this.section('九、测试注意事项/其他依赖项')}${this.noteTableHtml()}${this.section('十、已知问题与风险')}${this.riskTableHtml()}</div>`; }
},
watch: {
sprint(value) { this.rememberSprint(value); this.updateSubjectFromSprint(); },
to() { this.rememberRecipients(); },
cc() { this.rememberRecipients(); },
},
async mounted() { this.restoreRecipients(); await this.loadSprints(); await this.loadData(); },
methods: {
csrf() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); },
async loadSprints() { try { const data = await (await fetch('/api/test-mail/sprints')).json(); if (data.success) { this.sprintOptions = data.data.sprints || []; const savedSprint = this.restoreSprint(); if (!this.sprint && savedSprint) this.sprint = savedSprint; if (!this.sprint && this.sprintOptions.length) this.sprint = this.sprintOptions[0].id || this.sprintOptions[0].name || ''; this.defaults = data.data.defaults || {}; this.initializeContainers(); this.updateSubjectFromSprint(); } } catch (e) { console.error(e); } },
initializeContainers() { const groups = this.containerGroups; this.selectedGroups = groups.map(g => g.key); this.versions = Object.fromEntries(groups.map(g => [g.key, g.default_version || ''])); this.selectedContainers = groups.flatMap(g => (g.containers || []).map(c => c.name)); this.databases = []; this.refreshDatabases(); },
async loadData() { if (!this.sprint.trim()) { this.error = '请输入 Sprint'; return; } this.loading = true; this.error = ''; try { const data = await (await fetch(`/api/test-mail/data?sprint=${encodeURIComponent(this.sprint.trim())}`)).json(); if (!data.success) throw new Error(data.message || '加载失败'); this.issues = data.data.issues || []; this.defaults = data.data.defaults || this.defaults; this.sprintOptions = data.data.sprints || this.sprintOptions; if (!Object.keys(this.versions).length) this.initializeContainers(); this.jql = data.data.jql; this.subject = data.data.suggested_subject || this.buildSubject(this.resolveSprintPeriod() || (this.sprint.trim() ? `Sprint${this.sprint.trim()}` : '')); this.generateDraftSections(false); } catch (e) { this.error = e.message; } finally { this.loading = false; } },
handleSprintSelection() { this.updateSubjectFromSprint(); this.loadData(); },
rememberSprint(value) { try { const sprint = String(value || '').trim(); if (sprint) localStorage.setItem(LAST_SPRINT_STORAGE_KEY, sprint); else localStorage.removeItem(LAST_SPRINT_STORAGE_KEY); } catch (e) { console.error(e); } },
restoreSprint() { try { return localStorage.getItem(LAST_SPRINT_STORAGE_KEY) || ''; } catch (e) { console.error(e); return ''; } },
rememberRecipients() { try { localStorage.setItem(RECIPIENTS_STORAGE_KEY, JSON.stringify({to:this.to,cc:this.cc})); } catch (e) { console.error(e); } },
restoreRecipients() { try { const saved = JSON.parse(localStorage.getItem(RECIPIENTS_STORAGE_KEY) || 'null'); if (saved && typeof saved.to === 'string' && typeof saved.cc === 'string') { this.to = saved.to; this.cc = saved.cc; } } catch (e) { console.error(e); } },
removeIssue(key) { this.draftRequestId++; this.draftLoading = false; this.issues = this.issues.filter(issue => issue.key !== key); this.testNoteRows = this.testNoteRows.filter(row => row.issue !== key); if (!this.testNoteRows.length) this.addNoteRow(); },
async refreshDatabases() { try { const data = await (await fetch('/api/test-mail/databases', { method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body: JSON.stringify({ selected_groups: this.selectedGroups, versions: this.versions }) })).json(); if (data.success) this.databases = data.data.databases || []; } catch (e) { console.error(e); } },
isGroupSelected(key) { return this.selectedGroups.includes(key); },
toggleGroup(key, checked) { const group = this.containerGroups.find(g => g.key === key); const names = (group?.containers || []).map(c => c.name); if (checked) { if (!this.selectedGroups.includes(key)) this.selectedGroups.push(key); this.selectedContainers = Array.from(new Set([...this.selectedContainers, ...names])); } else { this.selectedGroups = this.selectedGroups.filter(k => k !== key); this.selectedContainers = this.selectedContainers.filter(n => !names.includes(n)); } this.refreshDatabases(); },
handlePaste(event, section) { for (const item of (event.clipboardData?.items || [])) if (item.type.startsWith('image/')) { event.preventDefault(); const file = item.getAsFile(); const reader = new FileReader(); reader.onload = () => { const cid = `${section}-${Date.now()}-${this.images.length}@toolbox.local`; this.images.push({cid,section,name:file.name || `${section}-${this.images.length+1}.png`,dataUrl:reader.result}); const img=document.createElement('img'); img.src=reader.result; img.style.maxWidth='100%'; img.style.display='block'; img.style.margin='8px 0'; this.$refs.environmentPasteBox.appendChild(img); }; reader.readAsDataURL(file); } },
clearEnvironmentScreenshots() { this.images = this.images.filter(i => i.section !== 'environment'); this.$refs.environmentPasteBox.innerHTML = '<p class="text-gray-500">点击这里后直接粘贴「八、环境部署准备清单」截图</p>'; },
addNoteRow() { this.testNoteRows.push({_id:Date.now()+Math.random(),type:'其他依赖项',issue:'',system:'',content:'',owner:''}); }, removeNoteRow(i) { this.testNoteRows.splice(i,1); if (!this.testNoteRows.length) this.addNoteRow(); },
addRiskRow() { this.riskRows.push({_id:Date.now()+Math.random(),problem:'',impact:'',action:'',owner:''}); }, removeRiskRow(i) { this.riskRows.splice(i,1); if (!this.riskRows.length) this.addRiskRow(); },
async generateDraftSections(showErrors = true) { const requestId = ++this.draftRequestId; this.draftLoading = true; if (showErrors) this.error = ''; try { const res = await fetch('/api/test-mail/draft-sections', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({ issues:this.issues, tech_docs:this.techDocs, selected_containers:this.selectedContainers, databases:this.databases }) }); const data = await res.json(); if (requestId !== this.draftRequestId) return; if (!data.success) throw new Error(data.message || '生成草稿失败'); this.testNoteRows = (data.data.test_notes || []).map((row, index) => ({_id:Date.now()+index, type:row.type || '测试注意事项', issue:row.issue || '', system:row.system || '', content:row.content || '', owner:row.owner || ''})); this.riskRows = (data.data.risks || []).map((row, index) => ({_id:Date.now()+100+index, problem:row.problem || '', impact:row.impact || '', action:row.action || '', owner:row.owner || ''})); if (!this.testNoteRows.length) this.addNoteRow(); if (!this.riskRows.length) this.addRiskRow(); this.draftSource = data.data.source || 'rules'; } catch(e) { if (showErrors) this.error = e.message; else console.error(e); } finally { if (requestId === this.draftRequestId) this.draftLoading = false; } },
updateSubjectFromSprint() { const sprint = this.sprint.trim(); this.subject = this.buildSubject(this.resolveSprintPeriod() || (sprint ? `Sprint${sprint}` : '')); },
buildSubject(period) { return `【提测】${period ? period : ''}需求提测(SP、PP、TP)`; },
resolveSprintPeriod() { const option = this.selectedSprintOption(); if (option?.period) return option.period; const candidates = [this.sprint, option?.name, option?.label, ...this.issues.map(i => i.sprint || '')]; for (const candidate of candidates) { const period = normalizeTestMailSprintPeriod(candidate); if (period) return period; } return ''; },
selectedSprintOption() { return this.sprintOptions.find(o => String(o.id || o.name) === String(this.sprint)); },
async openMailDraft() { if (this.draftOpening) return; const to = this.extractEmails(this.to); if (!to.length) { this.error = '请先填写收件人'; return; } this.draftOpening = true; this.error = ''; const text = this.plainText(); try { const res = await fetch('/api/test-mail/open-draft', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({subject:this.subject,from:this.from,to:this.to,cc:this.cc,html:this.mailHtml,text,images:this.images}) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) throw new Error(data.message || 'Thunderbird 打开失败'); this.error = data.message || '已向 Thunderbird 发送打开完整邮件草稿请求'; } catch (e) { await this.copyDraftBody(text); this.openMailtoFallback(text); } finally { setTimeout(() => { this.draftOpening = false; }, 1500); } },
async copyDraftBody(text) { try { await navigator.clipboard?.writeText(text); this.error = '无法直接写入正文,已复制正文到剪贴板;草稿打开后请粘贴。'; } catch (e) { this.error = '无法直接写入正文;请使用下载 .eml 获取完整邮件。'; } },
openMailtoFallback(text) { const to = this.extractEmails(this.to); const cc = this.extractEmails(this.cc); const body = text.length > 1200 ? '正文已复制到剪贴板,请在此处粘贴。' : text; const params = new URLSearchParams({ subject:this.subject, body }); if (cc.length) params.set('cc', cc.join(',')); window.location.href = `mailto:${to.join(',')}?${params.toString()}`; },
extractEmails(value) { return Array.from(new Set(String(value || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [])); },
async downloadEml() { this.downloading = true; this.error = ''; try { const res = await fetch('/api/test-mail/download', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({subject:this.subject,from:this.from,to:this.to,cc:this.cc,html:this.mailHtml,text:this.plainText(),images:this.images}) }); if (!res.ok) throw new Error(await res.text()); const blob=await res.blob(); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=`${this.subject.replace(/[\\/:*?"<>|]/g,'_')}.eml`; a.click(); URL.revokeObjectURL(url); } catch(e) { this.error='生成邮件失败: '+e.message; } finally { this.downloading=false; } },
section(t){return `<h1 style="font-size:14px;margin:16px 0 6px 0;font-weight:bold">${this.escape(t)}</h1>`;}, paragraph(t){return `<div style="margin:0 0 8px 0">${t || '无'}</div>`;}, multiline(t){return `<div style="margin:0 0 8px 0;white-space:pre-wrap">${this.escape(t || '无')}</div>`;}, smokeLinksHtml(){const lines=String(this.smokeUrl||'').split(/\r?\n/).map(line=>line.trim()).filter(Boolean); if(!lines.length)return ''; return `<div style="margin:0 0 8px 0">${lines.map(line=>/^https?:\/\//i.test(line)?`<div><a href="${this.escapeAttr(line)}">${this.escape(line)}</a></div>`:`<div>${this.escape(line)}</div>`).join('')}</div>`;}, screenshotHtml(s){return this.images.filter(i=>i.section===s).map(i=>`<div><img src="cid:${this.escapeAttr(i.cid)}" style="max-width:100%;height:auto" alt=""></div>`).join('') || '<div>无</div>';},
issueTableHtml(){return this.simpleTable(this.issueHeaders,this.issues.map(i=>({key:`<a href="${this.escapeAttr(i.url)}">${this.escape(i.key)}</a>`,summary:this.escape(i.summary),reporter:this.escape(i.reporter || ''),status:this.escape(i.status),developer:this.escape(i.developer || ''),assignee:this.escape(i.assignee || ''),sprint:this.escape(i.sprint || ''),estimated_test_at:this.escape(i.estimated_test_at || ''),estimated_release_at:this.escape(i.estimated_release_at || '')})),['key','summary','reporter','status','developer','assignee','sprint','estimated_test_at','estimated_release_at'],true);},
noteTableHtml(){const rows=this.testNoteRows.filter(r=>['issue','system','content','owner'].some(k=>String(r[k]??'').trim())); return this.simpleTable(this.noteHeaders,rows,['type','issue','system','content','owner']);}, riskTableHtml(){const rows=this.riskRows.filter(r=>['problem','impact','action','owner'].some(k=>String(r[k]??'').trim())); return this.simpleTable(this.riskHeaders,rows,['problem','impact','action','owner']);},
simpleTable(headers, rows, keys, trusted=false){if(!rows.length)return '<div>无</div>'; return `<table border="1" cellspacing="0" cellpadding="4" style="border-collapse:collapse;font-size:14px;margin:6px 0 14px 0"><thead><tr>${headers.map(h=>`<th style="background:#f2f2f2">${this.escape(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${keys.map(k=>`<td>${trusted?(r[k]||''):this.escape(String(r[k]??'')).replace(/\n/g,'<br>')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;},
plainText(){return this.mailHtml.replace(/<br\s*\/?>(\s*)/gi,'\n').replace(/<[^>]+>/g,'').replace(/&nbsp;/g,' ').replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>');}, escape(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}, escapeAttr(v){return this.escape(v).replace(/'/g,'&#39;');}
}
}
</script>
<style scoped>
.btn-primary { border-radius: 0.375rem; background: #2563eb; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { opacity: .5; }
.btn-secondary { border-radius: 0.375rem; background: #f3f4f6; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: #374151; }
.btn-secondary:hover { background: #e5e7eb; }
.btn-success { border-radius: 0.375rem; background: #16a34a; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: white; }
.btn-success:hover { background: #15803d; }
.btn-success:disabled { opacity: .5; }
.dense-card { border-radius: 0.75rem; border: 1px solid #e5e7eb; background: #fff; padding: 1rem; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.dense-title { margin-bottom: .75rem; font-size: .875rem; font-weight: 600; color: #111827; }
.compact-field { display: block; font-size: .75rem; font-weight: 500; color: #4b5563; }
.control { margin-top: .25rem; width: 100%; border-radius: .375rem; border: 1px solid #d1d5db; padding: .375rem .5rem; font-size: .875rem; color: #111827; }
.control:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
.dense-table { width: 100%; border-collapse: collapse; background: #fff; font-size: .75rem; }
.dense-table th { position: sticky; top: 0; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; background: #f3f4f6; padding: .375rem .5rem; text-align: left; font-weight: 600; color: #374151; }
.dense-table td { border-right: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6; padding: .375rem .5rem; vertical-align: top; color: #1f2937; }
.table-control { width: 100%; border-radius: .25rem; border: 1px solid #e5e7eb; padding: .25rem .375rem; font-size: .75rem; }
.table-control:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
</style>
+29 -4
View File
@@ -3,12 +3,12 @@
<!-- 页面标题 --> <!-- 页面标题 -->
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">生成周报</h1> <h1 class="text-2xl font-bold text-gray-900">生成周报</h1>
<p class="text-gray-600 mt-2">生成上周的工作周报</p> <p class="text-gray-600 mt-2">按周选择统计范围生成对应周期的工作周报</p>
</div> </div>
<!-- 周报生成区域 --> <!-- 周报生成区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">生成上周周报</h2> <h2 class="text-xl font-semibold text-gray-700 mb-4">生成{{ selectedPeriodLabel }}周报</h2>
<div class="flex flex-wrap gap-4 mb-4"> <div class="flex flex-wrap gap-4 mb-4">
<div class="flex-1 min-w-64"> <div class="flex-1 min-w-64">
@@ -20,6 +20,17 @@
placeholder="输入 JIRA 用户名" placeholder="输入 JIRA 用户名"
> >
</div> </div>
<div class="w-40">
<label class="block text-sm font-medium text-gray-700 mb-2">统计周期</label>
<select
v-model="weeklyReport.period"
@change="resetWeeklyReportResult"
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="last_week">上周</option>
<option value="this_week">本周</option>
</select>
</div>
<div class="flex items-end"> <div class="flex items-end">
<button <button
@click="generateWeeklyReport" @click="generateWeeklyReport"
@@ -73,6 +84,7 @@ export default {
return { return {
weeklyReport: { weeklyReport: {
username: '', username: '',
period: 'this_week',
loading: false, loading: false,
result: '', result: '',
error: '' error: ''
@@ -80,11 +92,22 @@ export default {
} }
}, },
computed: {
selectedPeriodLabel() {
return this.weeklyReport.period === 'this_week' ? '本周' : '上周';
}
},
async mounted() { async mounted() {
// 获取默认用户名 // 获取默认用户名
await this.loadDefaultUser(); await this.loadDefaultUser();
}, },
methods: { methods: {
resetWeeklyReportResult() {
this.weeklyReport.result = '';
this.weeklyReport.error = '';
},
async loadDefaultUser() { async loadDefaultUser() {
this.weeklyReport.username = resolveJiraDefaultQueryUser(''); this.weeklyReport.username = resolveJiraDefaultQueryUser('');
@@ -118,7 +141,8 @@ export default {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}, },
body: JSON.stringify({ body: JSON.stringify({
username: this.weeklyReport.username username: this.weeklyReport.username,
period: this.weeklyReport.period
}) })
}); });
@@ -163,7 +187,8 @@ export default {
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
username: this.weeklyReport.username username: this.weeklyReport.username,
period: this.weeklyReport.period
}); });
window.open(`/api/jira/weekly-report/download?${params}`, '_blank'); window.open(`/api/jira/weekly-report/download?${params}`, '_blank');
@@ -1,133 +1,106 @@
<template> <template>
<div class="p-6"> <div class="p-4">
<!-- 页面标题 --> <!-- 页面标题 -->
<div class="mb-6"> <div class="mb-3 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">消息同步</h1>
<p class="text-gray-600 mt-2">批量输入消息ID从crmslave数据库查询并同步到agent服务</p>
</div>
<!-- 输入区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">消息ID输入</h2>
<div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <h1 class="text-lg font-bold text-gray-900">消息同步</h1>
消息ID列表 (每行一个ID) <p class="text-xs text-gray-500 mt-0.5">输入消息ID通过Mono服务重新消费并分发消息</p>
</label>
<textarea
v-model="messageIdsText"
rows="8"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入消息ID,每行一个&#10;例如:&#10;af7e5ca7-2779-0e9e-93d1-68c79ceffd9033&#10;bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
></textarea>
<div class="text-sm text-gray-500 mt-1">
{{ messageIdsList.length }} 个消息ID
</div> </div>
</div>
<div class="flex space-x-4">
<button
@click="queryMessages"
:disabled="loading.query || messageIdsList.length === 0"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.query" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
查询消息
</button>
<button
@click="syncMessages"
:disabled="loading.sync || !queryResults || messageIdsList.length === 0"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.sync" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
执行同步
</button>
<button <button
@click="testConnection" @click="testConnection"
:disabled="loading.test" :disabled="loading.test"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center" class="px-3 py-1.5 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200 disabled:opacity-50 flex items-center"
> >
<svg v-if="loading.test" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <svg v-if="loading.test" class="animate-spin -ml-0.5 mr-1.5 h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
测试连接 测试连接
</button> </button>
</div> </div>
</div>
</div>
<!-- 错误信息 --> <!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"> <div v-if="error" class="bg-red-50 border border-red-200 rounded px-3 py-2 mb-3 flex items-start text-sm">
<div class="flex"> <svg class="w-4 h-4 text-red-400 mr-1.5 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<div> <span class="text-red-700">{{ error }}</span>
<h3 class="text-sm font-medium text-red-800">错误</h3> </div>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
<!-- 输入区域 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-3">
<div class="flex gap-4">
<div class="flex-1">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-medium text-gray-600">消息ID每行一个</label>
<span class="text-xs text-gray-400">{{ messageIdsList.length }} </span>
</div>
<textarea
v-model="messageIdsText"
rows="6"
class="w-full border border-gray-300 rounded px-2.5 py-1.5 text-sm font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500 resize-none"
placeholder="af7e5ca7-2779-0e9e-93d1-68c79ceffd9033&#10;bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
></textarea>
</div>
<div class="flex flex-col gap-2 pt-6">
<button
@click="queryMessages"
:disabled="loading.query || messageIdsList.length === 0"
class="px-4 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center whitespace-nowrap"
>
<svg v-if="loading.query" class="animate-spin -ml-0.5 mr-1.5 h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
查询消息
</button>
<button
@click="syncMessages"
:disabled="loading.sync || messageIdsList.length === 0"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center whitespace-nowrap"
>
<svg v-if="loading.sync" class="animate-spin -ml-0.5 mr-1.5 h-3.5 w-3.5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
执行同步
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 查询结果 --> <!-- 查询结果 -->
<div v-if="queryResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"> <div v-if="queryResults" class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-3">
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询结果</h2> <div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">查询结果</h2>
<!-- 统计信息 --> <div class="flex gap-4 text-xs">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <span class="text-blue-600">请求 <b>{{ queryResults.stats.total_requested }}</b></span>
<div class="bg-blue-50 rounded-lg p-4"> <span class="text-green-600">找到 <b>{{ queryResults.stats.total_found }}</b></span>
<div class="text-2xl font-bold text-blue-600">{{ queryResults.stats.total_requested }}</div> <span class="text-red-600">缺失 <b>{{ queryResults.stats.total_missing }}</b></span>
<div class="text-sm text-blue-600">请求总数</div> <span class="text-purple-600">类型 <b>{{ Object.keys(queryResults.stats.event_types).length }}</b></span>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ queryResults.stats.total_found }}</div>
<div class="text-sm text-green-600">找到记录</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ queryResults.stats.total_missing }}</div>
<div class="text-sm text-red-600">缺失记录</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">{{ Object.keys(queryResults.stats.event_types).length }}</div>
<div class="text-sm text-purple-600">事件类型</div>
</div> </div>
</div> </div>
<!-- 消息列表 --> <!-- 消息列表 -->
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full text-sm">
<thead class="bg-gray-50"> <thead>
<tr> <tr class="border-b border-gray-200 text-xs text-gray-500">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th> <th class="text-left py-2 pr-3 font-medium">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">事件类型</th> <th class="text-left py-2 pr-3 font-medium">事件类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">跟踪ID</th> <th class="text-left py-2 pr-3 font-medium">跟踪ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th> <th class="text-left py-2 pr-3 font-medium">时间</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th> <th class="text-left py-2 font-medium w-12"></th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="divide-y divide-gray-100">
<tr v-for="message in queryResults.messages" :key="message.msg_id"> <tr v-for="message in queryResults.messages" :key="message.msg_id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ message.msg_id }}</td> <td class="py-1.5 pr-3 font-mono text-xs text-gray-900">{{ message.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ message.event_type }}</td> <td class="py-1.5 pr-3 text-gray-700">{{ message.event_type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500">{{ message.trace_id }}</td> <td class="py-1.5 pr-3 font-mono text-xs text-gray-400">{{ message.trace_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatTimestamp(message.timestamp) }}</td> <td class="py-1.5 pr-3 text-xs text-gray-500 whitespace-nowrap">{{ formatTimestamp(message.timestamp) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="py-1.5">
<button <button @click="showMessageDetail(message)" class="text-blue-500 hover:text-blue-700 text-xs">详情</button>
@click="showMessageDetail(message)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -136,57 +109,39 @@
</div> </div>
<!-- 同步结果 --> <!-- 同步结果 -->
<div v-if="syncResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div v-if="syncResults" class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h2 class="text-xl font-semibold text-gray-700 mb-4">同步结果</h2> <div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">同步结果</h2>
<!-- 同步统计 --> <div class="flex gap-4 text-xs">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <span class="text-blue-600">总计 <b>{{ syncResults.summary.total }}</b></span>
<div class="bg-blue-50 rounded-lg p-4"> <span class="text-green-600">成功 <b>{{ syncResults.summary.success }}</b></span>
<div class="text-2xl font-bold text-blue-600">{{ syncResults.summary.total }}</div> <span class="text-red-600">失败 <b>{{ syncResults.summary.failure }}</b></span>
<div class="text-sm text-blue-600">总计</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ syncResults.summary.success }}</div>
<div class="text-sm text-green-600">成功</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ syncResults.summary.failure }}</div>
<div class="text-sm text-red-600">失败</div>
</div> </div>
</div> </div>
<!-- 同步结果列表 --> <!-- 同步结果列表 -->
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full text-sm">
<thead class="bg-gray-50"> <thead>
<tr> <tr class="border-b border-gray-200 text-xs text-gray-500">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th> <th class="text-left py-2 pr-3 font-medium">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th> <th class="text-left py-2 pr-3 font-medium w-16">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">响应</th> <th class="text-left py-2 pr-3 font-medium">响应</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th> <th class="text-left py-2 font-medium w-12"></th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="divide-y divide-gray-100">
<tr v-for="result in syncResults.results" :key="result.msg_id"> <tr v-for="result in syncResults.results" :key="result.msg_id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ result.msg_id }}</td> <td class="py-1.5 pr-3 font-mono text-xs text-gray-900">{{ result.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="py-1.5 pr-3">
<span v-if="result.success" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span v-if="result.success" class="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">成功</span>
成功 <span v-else class="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">失败</span>
</span>
<span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
失败
</span>
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate"> <td class="py-1.5 pr-3 text-xs text-gray-500 max-w-md truncate">
{{ result.success ? '同步成功' : result.error }} {{ result.success ? '消息消费成功' : result.error }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="py-1.5">
<button <button @click="showSyncDetail(result)" class="text-blue-500 hover:text-blue-700 text-xs">详情</button>
@click="showSyncDetail(result)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -195,20 +150,18 @@
</div> </div>
<!-- 详情模态框 --> <!-- 详情模态框 -->
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50"> <div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50" @click.self="closeDetailModal">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white"> <div class="relative top-16 mx-auto p-4 border w-11/12 md:w-2/3 lg:w-1/2 shadow-lg rounded-lg bg-white">
<div class="mt-3"> <div class="flex justify-between items-center mb-3">
<div class="flex justify-between items-center mb-4"> <h3 class="text-sm font-semibold text-gray-900">详细信息</h3>
<h3 class="text-lg font-medium text-gray-900">详细信息</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600"> <button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
</div> </div>
<div class="max-h-96 overflow-y-auto"> <div class="max-h-[70vh] overflow-y-auto">
<pre class="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">{{ JSON.stringify(selectedDetail, null, 2) }}</pre> <pre class="bg-gray-50 p-3 rounded text-xs font-mono overflow-x-auto leading-relaxed">{{ JSON.stringify(selectedDetail, null, 2) }}</pre>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,402 @@
<template>
<div class="production-diagnosis h-full flex flex-col p-4 box-border gap-4 overflow-y-auto">
<!-- 输入区 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-sm lg:text-base font-semibold text-gray-900 flex items-center">
<svg class="w-4 h-4 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
进产诊断
</h2>
<span class="text-xs text-gray-500">输入病例 / 业务单据 / 销售单据编号查找无法进产的原因</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-[160px_1fr_auto] gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">单据类型</label>
<select
v-model="type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white"
>
<option v-for="opt in typeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">单据编号</label>
<input
v-model.trim="code"
type="text"
:placeholder="currentPlaceholder"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
@keyup.enter="diagnose"
/>
</div>
<div class="flex items-end gap-2">
<button
type="button"
@click="clearAll"
class="px-3 py-2 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>
清空
</button>
<button
type="button"
@click="diagnose"
:disabled="loading || !code"
class="px-3 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60"
>
<span v-if="loading">诊断中...</span>
<span v-else>开始诊断</span>
</button>
</div>
</div>
<p class="text-xs text-gray-500">
进产逻辑参考 agent-be:
<code class="font-mono">AgentCase\\ConfirmProduction::canProduction</code> /
<code class="font-mono">AgentBusinessDocument\\ConfirmProduction::canProduction</code> /
<code class="font-mono">AgentSaleDocument\\ConfirmPermit::canPermit</code>
</p>
<p class="text-xs text-gray-500">
进产原因来自 CRM <code class="font-mono">ea_case_cstm.label_bit</code> agent-be
<code class="font-mono">stuck_payment_reason</code> 配置过滤后写入
<code class="font-mono">cases.is_need_pfp</code>
</p>
</div>
<!-- 错误 -->
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
{{ errorMessage }}
</div>
<!-- 结果 -->
<div v-if="result" class="flex flex-col gap-4">
<!-- 概览 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-3">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
:class="overallBadgeClass"
>
{{ overallBadge }}
</span>
<span class="text-sm font-semibold text-gray-900">
{{ result.type_label }} · {{ result.code }}
</span>
</div>
<span v-if="result.found && result.entity" class="text-xs text-gray-500">
状态: {{ result.entity.status_label }} ({{ result.entity.status }})
<span class="mx-2 text-gray-300">|</span>
归属代理: {{ result.entity.agent_code || '-' }}
<span class="mx-2 text-gray-300">|</span>
产品: {{ result.entity.product_code || '-' }}
</span>
</div>
<div v-if="!result.found" class="mt-3 text-sm text-gray-600">
{{ result.message }}
</div>
<div v-else-if="result.entity" class="mt-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div v-for="field in entityFields" :key="field.key" class="bg-gray-50 rounded-lg p-2">
<div class="text-gray-500">{{ field.label }}</div>
<div class="font-mono text-gray-900 break-all">{{ field.value || '-' }}</div>
</div>
</div>
</div>
<!-- 检查项列表 -->
<div v-if="result.found && result.checks" class="space-y-3">
<div
v-for="check in result.checks"
:key="check.key"
class="bg-white rounded-xl shadow-sm border p-4"
:class="check.pass ? 'border-emerald-200' : 'border-red-200'"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3 min-w-0">
<span
class="mt-0.5 inline-flex items-center justify-center w-6 h-6 rounded-full flex-shrink-0 text-xs font-bold"
:class="check.pass ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'"
>
{{ check.pass ? '✓' : '✗' }}
</span>
<div class="min-w-0">
<div class="text-sm font-semibold text-gray-900">{{ check.label }}</div>
<div class="text-xs text-gray-600 mt-1">{{ check.detail }}</div>
</div>
</div>
<span
class="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium flex-shrink-0"
:class="check.pass ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'"
>
{{ check.pass ? '通过' : '未通过' }}
</span>
</div>
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-500">期望</div>
<div class="font-mono text-gray-900 break-all">{{ check.expected }}</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-500">实际</div>
<div class="font-mono text-gray-900 break-all">{{ check.actual }}</div>
</div>
</div>
<div v-if="!check.pass && check.hint" class="mt-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
提示{{ check.hint }}
</div>
<!-- 进产原因展示 -->
<div v-if="check.key === 'need_pfp'" class="mt-3 space-y-3">
<div>
<div class="text-xs text-gray-500 mb-1">
进产卡款原因
<span class="ml-2 text-gray-400">来源CRM label_bit &amp; stuck_payment_reason 配置</span>
</div>
<div v-if="check.reasons && check.reasons.length" class="space-y-2">
<div
v-for="reason in check.reasons"
:key="reason.bit"
class="border border-amber-200 bg-amber-50 rounded-lg p-2"
>
<div class="text-xs font-semibold text-amber-800">
{{ reason.label }}
<span class="ml-1 font-mono font-normal text-amber-600">bit {{ reason.bit }}</span>
<span v-if="reason.crm_label !== reason.label" class="ml-2 font-normal text-amber-600">
CRM: {{ reason.crm_label }}
</span>
</div>
<div class="text-xs text-amber-700 mt-1">{{ reason.description }}</div>
</div>
</div>
<div v-else class="text-xs text-gray-600 bg-gray-50 rounded-lg p-2">
该病例没有任何卡生产原因不需要代理放行
</div>
</div>
<div
v-if="check.crm_ignored_reasons && check.crm_ignored_reasons.length"
class="border border-blue-200 bg-blue-50 rounded-lg p-2"
>
<div class="text-xs font-semibold text-blue-800">CRM 有标记但未纳入配置</div>
<div
v-for="ignored in check.crm_ignored_reasons"
:key="ignored.bit"
class="text-xs text-blue-700 mt-1"
>
{{ ignored.label }} (bit {{ ignored.bit }}) {{ ignored.description }}
</div>
</div>
<div
v-if="check.sync_mismatch"
class="border border-orange-200 bg-orange-50 rounded-lg p-2 text-xs text-orange-800"
>
代理库与 CRM 不一致CRM label_bit = {{ check.crm_label_bit }}按配置应为
is_need_pfp = {{ check.expected_is_need_pfp }}实际为 {{ check.is_need_pfp }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-500">CRM label_bit</div>
<div class="text-gray-900 break-all">
{{ check.crm_available ? check.crm_label_bit + ' · ' + check.crm_label_bit_text : check.crm_label_bit_text }}
</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-500">放行状态 (is_pfp)</div>
<div class="text-gray-900">{{ check.is_pfp_text }} ({{ check.is_pfp }})</div>
</div>
<div class="bg-gray-50 rounded p-2">
<div class="text-gray-500">配置来源</div>
<div class="text-gray-900 break-all">
{{ check.config_source_text }} · 掩码 {{ check.config_mask }}
</div>
</div>
</div>
<div v-if="check.config_options && check.config_options.length" class="text-xs text-gray-500">
当前 stuck_payment_reason
<span
v-for="option in check.config_options"
:key="option.bit"
class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-700 mr-1 font-mono"
>
{{ option.bit }} · {{ option.label }}
</span>
</div>
</div>
<!-- 账期链路展示 -->
<div v-if="check.key === 'credit' && check.chain && check.chain.length" class="mt-3">
<div class="text-xs text-gray-500 mb-1">
代理账期链路 (产品 {{ check.product_code || '-' }})
<span v-if="!check.crm_configured" class="ml-2 text-amber-700">
· CRM 未配置一级账期视为未知
</span>
<span v-else-if="!check.first_agent_credit_resolved" class="ml-2 text-amber-700">
· CRM 调用失败
</span>
</div>
<div class="border border-gray-200 rounded-lg overflow-hidden">
<table class="w-full text-xs">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th class="px-2 py-1 text-left font-medium">层级</th>
<th class="px-2 py-1 text-left font-medium">代理编号</th>
<th class="px-2 py-1 text-left font-medium">代理名称</th>
<th class="px-2 py-1 text-left font-medium">是否有账期</th>
<th class="px-2 py-1 text-left font-medium">来源</th>
</tr>
</thead>
<tbody>
<tr
v-for="(node, idx) in check.chain"
:key="idx"
:class="[
node.broken ? 'bg-gray-50 text-gray-400' : '',
idx === check.chain.length - 1 ? 'border-t border-gray-100' : 'border-t border-gray-100'
]"
>
<td class="px-2 py-1 font-mono">
{{ node.is_root ? '一级' : (node.level != null ? node.level + '级' : '-') }}
</td>
<td class="px-2 py-1 font-mono">{{ node.agent_code }}</td>
<td class="px-2 py-1">{{ node.agent_name || '-' }}</td>
<td class="px-2 py-1">
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px]"
:class="node.has_credit ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'"
>
{{ node.has_credit ? '有' : '无' }}
</span>
<span v-if="node.broken" class="ml-2 text-gray-400">(链路已中断)</span>
</td>
<td class="px-2 py-1 font-mono text-gray-500">{{ node.credit_source }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProductionDiagnosis',
data() {
return {
type: 'case',
code: '',
loading: false,
errorMessage: '',
result: null,
typeOptions: [
{ value: 'case', label: '病例', placeholder: '示例: C01008446046 / X0X60F' },
{ value: 'business_document', label: '业务单据', placeholder: '示例: B12345678 / LX5KP3' },
{ value: 'sale_document', label: '销售单据', placeholder: '示例: 销售单据 code' }
]
};
},
computed: {
currentPlaceholder() {
const opt = this.typeOptions.find((o) => o.value === this.type);
return opt ? opt.placeholder : '';
},
overallBadge() {
if (!this.result) return '';
if (!this.result.found) return '未找到';
return this.result.can_production ? '可以进产' : '不能进产';
},
overallBadgeClass() {
if (!this.result) return '';
if (!this.result.found) return 'bg-gray-50 text-gray-600 border-gray-200';
return this.result.can_production
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: 'bg-red-50 text-red-700 border-red-200';
},
entityFields() {
if (!this.result || !this.result.entity) return [];
const e = this.result.entity;
const base = [
{ key: 'code', label: '编号', value: e.code },
{ key: 'agent_code', label: '归属代理 (agent_code)', value: e.agent_code },
{ key: 'settlement_agent_code', label: '结算代理', value: e.settlement_agent_code },
{ key: 'product_code', label: '产品编号', value: e.product_code }
];
if (this.result.type === 'case') {
base.push(
{ key: 'hospital_code', label: '机构编号', value: e.hospital_code },
{ key: 'doctor_code', label: '医生编号', value: e.doctor_code },
{ key: 'patient_name', label: '患者姓名', value: e.patient_name },
{
key: 'is_need_pfp',
label: '进产原因 (is_need_pfp)',
value: `${e.debt_reason_text || '无'} (${e.is_need_pfp})`
},
{ key: 'is_pfp', label: '放行状态 (is_pfp)', value: `${e.is_pfp_text || '-'} (${e.is_pfp})` }
);
} else {
base.push({ key: 'hospital_code', label: '机构编号', value: e.hospital_code });
}
return base;
}
},
methods: {
clearAll() {
this.code = '';
this.result = null;
this.errorMessage = '';
},
async diagnose() {
if (!this.code) {
this.errorMessage = '请输入单据编号';
return;
}
this.loading = true;
this.errorMessage = '';
this.result = null;
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const response = await fetch('/api/production-diagnosis/diagnose', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': csrfMeta ? csrfMeta.getAttribute('content') : ''
},
body: JSON.stringify({ type: this.type, code: this.code })
});
const data = await response.json();
if (!response.ok || !data.success) {
this.errorMessage = data.message || '诊断失败,请稍后重试';
return;
}
this.result = data.data;
} catch (err) {
this.errorMessage = '网络请求失败: ' + err.message;
} finally {
this.loading = false;
}
}
}
};
</script>
<style scoped>
.production-diagnosis {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>
+227 -4
View File
@@ -33,7 +33,7 @@
<textarea <textarea
v-model="inputText" v-model="inputText"
rows="8" rows="8"
placeholder="示例: X0X60F 17141\nC01008446046, 11894" :placeholder="currentTool.placeholder"
class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono" class="w-full flex-1 min-h-[200px] px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
></textarea> ></textarea>
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
@@ -63,7 +63,7 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col min-h-0">
<div class="flex flex-col gap-4 h-full min-h-0"> <div class="flex flex-col gap-4 h-full min-h-0">
<div> <div v-if="showQuerySql">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">查询SQL</h3> <h3 class="text-sm lg:text-base font-semibold text-gray-900">查询SQL</h3>
<button <button
@@ -83,7 +83,50 @@
></textarea> ></textarea>
</div> </div>
<div class="flex flex-col flex-1 min-h-0"> <div v-if="showSplitOutput" class="flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
<div v-if="stats.total" class="text-xs text-gray-500">
{{ stats.total }} 更新 {{ stats.update }}
</div>
</div>
<div class="grid grid-rows-3 gap-3 flex-1 min-h-0">
<div
v-for="section in splitOutputSections"
:key="section.key"
class="flex flex-col min-h-0 border border-gray-200 rounded-lg overflow-hidden bg-gray-50"
>
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-white">
<div class="text-sm font-semibold text-gray-900">{{ section.label }}</div>
<button
@click="copySplitOutput(section.key)"
:disabled="!splitOutputSql[section.key]"
class="px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-60"
type="button"
>
复制{{ section.label }}
</button>
</div>
<textarea
v-model="splitOutputSql[section.key]"
readonly
class="w-full flex-1 min-h-[120px] px-3 py-2 border-0 text-sm font-mono bg-gray-50 resize-none focus:ring-0"
></textarea>
</div>
</div>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-400">结果仅用于复制执行请确认无误后使用</p>
</div>
<div
v-if="copyStatus.message"
class="mt-2 text-xs"
:class="copyStatus.type === 'success' ? 'text-green-600' : 'text-red-600'"
>
{{ copyStatus.message }}
</div>
</div>
<div v-else class="flex flex-col flex-1 min-h-0">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3> <h3 class="text-sm lg:text-base font-semibold text-gray-900">生成结果</h3>
<div v-if="stats.total" class="text-xs text-gray-500"> <div v-if="stats.total" class="text-xs text-gray-500">
@@ -143,6 +186,11 @@ export default {
selectedTool: 'ob-external-id', selectedTool: 'ob-external-id',
inputText: '', inputText: '',
outputSql: '', outputSql: '',
splitOutputSql: {
sp: '',
ppCn: '',
ppUs: ''
},
querySql: '', querySql: '',
loading: false, loading: false,
errors: [], errors: [],
@@ -161,7 +209,14 @@ export default {
{ {
value: 'ob-external-id', value: 'ob-external-id',
label: 'OB外部ID', label: 'OB外部ID',
description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。' description: '每行输入 case_id 与 ob_id,系统会判断是否生成更新或插入 SQL。',
placeholder: '示例: X0X60F 17141\nC01008446046, 11894'
},
{
value: 'new-factory-return-redelivery',
label: '新区工厂退库重新出库',
description: '每行输入加工单、病例号、正确运单,只读取前三列;根据 CRM 加工单关联地址国家拆分 PP-CN / PP-US SQL。',
placeholder: '示例: M20260508006905 225KF9 SF123456789\nM20260509005949 C01005934247 UPS987654321'
} }
] ]
} }
@@ -169,12 +224,29 @@ export default {
computed: { computed: {
currentTool() { currentTool() {
return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0]; return this.toolOptions.find((tool) => tool.value === this.selectedTool) || this.toolOptions[0];
},
showSplitOutput() {
return this.selectedTool === 'new-factory-return-redelivery';
},
showQuerySql() {
return !this.showSplitOutput;
},
splitOutputSections() {
return [
{ key: 'sp', label: 'SP' },
{ key: 'ppCn', label: 'PP-CN' },
{ key: 'ppUs', label: 'PP-US' }
];
} }
}, },
methods: { methods: {
clearInput() { clearInput() {
this.inputText = ''; this.inputText = '';
this.outputSql = ''; this.outputSql = '';
this.resetSplitOutputSql();
this.querySql = ''; this.querySql = '';
this.errors = []; this.errors = [];
this.warnings = []; this.warnings = [];
@@ -249,6 +321,7 @@ export default {
async generateSql() { async generateSql() {
this.errors = []; this.errors = [];
this.outputSql = ''; this.outputSql = '';
this.resetSplitOutputSql();
this.querySql = ''; this.querySql = '';
this.warnings = []; this.warnings = [];
this.resetCopyStatus(); this.resetCopyStatus();
@@ -259,6 +332,11 @@ export default {
return; return;
} }
if (this.selectedTool === 'new-factory-return-redelivery') {
await this.generateNewFactoryReturnRedeliverySql();
return;
}
this.errors = ['未识别的功能类型,请重新选择。']; this.errors = ['未识别的功能类型,请重新选择。'];
}, },
@@ -321,6 +399,113 @@ export default {
} }
}, },
parseNewFactoryReturnRedeliveryInput() {
const lines = this.inputText.split(/\r?\n/);
const errors = [];
const rows = [];
lines.forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
const parts = trimmed.split(/[\s,]+/).filter(Boolean);
if (parts.length < 3) {
errors.push(`${index + 1} 行格式不正确,请提供加工单、病例号、正确运单三列`);
return;
}
rows.push({
productionCode: parts[0],
caseCode: parts[1],
expressNo: parts[2],
lineNumber: index + 1
});
});
if (rows.length === 0 && errors.length === 0) {
errors.push('请输入至少一行加工单、病例号、正确运单。');
}
return {
rows,
errors
};
},
async generateNewFactoryReturnRedeliverySql() {
const { rows, errors } = this.parseNewFactoryReturnRedeliveryInput();
if (errors.length) {
this.errors = errors;
return;
}
this.loading = true;
try {
const productionCodes = [...new Set(rows.map((row) => row.productionCode))];
this.querySql = this.buildProductionCountriesSelect(productionCodes);
const response = await fetch('/api/sql-generator/production-countries/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
production_codes: productionCodes
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.errors = [data.message || '查询 CRM 加工单国家失败,请稍后重试。'];
return;
}
const productionCountries = data.data.production_countries || {};
const missingRows = rows.filter((row) => !productionCountries[row.productionCode] || productionCountries[row.productionCode].length === 0);
if (missingRows.length) {
this.errors = missingRows.map((row) => `${row.lineNumber} 行加工单 ${row.productionCode} 未在 CRM 查询到地址国家`);
return;
}
const spSql = [];
const ppCnSql = [];
const ppUsSql = [];
rows.forEach((row) => {
const productionCode = this.escapeSqlValue(row.productionCode);
const expressNo = this.escapeSqlValue(row.expressNo);
const ppSql = this.isUsProductionCountry(productionCountries[row.productionCode]) ? ppUsSql : ppCnSql;
spSql.push(`update case_deliveries set express_no = '${expressNo}' where production_code = '${productionCode}';`);
ppSql.push(`update delivery_records set express_no = '${expressNo}' where production_code = '${productionCode}';`);
ppSql.push(`update receives set express_no = '${expressNo}' where production_code = '${productionCode}';`);
});
const sections = [
this.buildSqlSection('SP', spSql),
this.buildSqlSection('PP-CN', ppCnSql),
this.buildSqlSection('PP-US', ppUsSql)
].filter(Boolean);
this.stats.update = spSql.length + ppCnSql.length + ppUsSql.length;
this.stats.total = this.stats.update;
this.splitOutputSql = {
sp: spSql.join('\n'),
ppCn: ppCnSql.join('\n'),
ppUs: ppUsSql.join('\n')
};
this.outputSql = sections.join('\n\n');
} catch (error) {
this.errors = ['网络请求失败: ' + error.message];
} finally {
this.loading = false;
}
},
escapeSqlValue(value) { escapeSqlValue(value) {
return String(value).replace(/'/g, "''"); return String(value).replace(/'/g, "''");
}, },
@@ -334,6 +519,32 @@ export default {
return `select * from case_extras where case_code in (${inValues})`; return `select * from case_extras where case_code in (${inValues})`;
}, },
buildProductionCountriesSelect(productionCodes) {
if (!productionCodes.length) {
return '';
}
const inValues = productionCodes.map((productionCode) => `'${this.escapeSqlValue(productionCode)}'`).join(', ');
return [
'select ep.name, epc.ea_case_id_c, epc.ea_businessorder_id_c, epc.ea_salesorder_id_c',
'from ea_production ep',
'join ea_production_cstm epc on ep.id = epc.id_c',
`where ep.deleted = 0 and ep.name in (${inValues})`
].join('\n');
},
isUsProductionCountry(countryCodes) {
return (countryCodes || []).some((countryCode) => ['US', 'GU', 'PR'].includes(String(countryCode).toUpperCase()));
},
buildSqlSection(title, sqlLines) {
if (!sqlLines.length) {
return '';
}
return [`-- ${title}`, ...sqlLines].join('\n');
},
buildDuplicateWarnings(duplicates) { buildDuplicateWarnings(duplicates) {
return duplicates.map((duplicate) => { return duplicates.map((duplicate) => {
const lines = duplicate.lineNumbers.join('、'); const lines = duplicate.lineNumbers.join('、');
@@ -348,6 +559,14 @@ export default {
}; };
}, },
resetSplitOutputSql() {
this.splitOutputSql = {
sp: '',
ppCn: '',
ppUs: ''
};
},
setCopyStatus(message, type) { setCopyStatus(message, type) {
this.copyStatus = { this.copyStatus = {
message, message,
@@ -433,6 +652,10 @@ export default {
await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea'); await this.copyToClipboard(this.outputSql, '复制失败,请手动复制结果。', 'outputTextarea');
}, },
async copySplitOutput(key) {
await this.copyToClipboard(this.splitOutputSql[key], '复制失败,请手动复制结果。');
},
async copyQuery() { async copyQuery() {
await this.copyToClipboard(this.querySql, '复制失败,请手动复制查询SQL。', 'queryTextarea'); await this.copyToClipboard(this.querySql, '复制失败,请手动复制查询SQL。', 'queryTextarea');
} }
+8
View File
@@ -0,0 +1,8 @@
import './bootstrap';
import { createApp } from 'vue';
import ProductionDiagnosis from './components/tools/ProductionDiagnosis.vue';
const app = createApp({});
app.component('production-diagnosis', ProductionDiagnosis);
app.mount('#app');
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>进产诊断</title>
@vite(['resources/css/app.css', 'resources/js/production-diagnosis.js'])
</head>
<body class="bg-gray-100">
<div id="app" class="min-h-screen">
<production-diagnosis></production-diagnosis>
</div>
</body>
</html>
+38 -7
View File
@@ -1,18 +1,23 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\EnvController;
use App\Http\Controllers\JiraController;
use App\Http\Controllers\LogAnalysisController;
use App\Http\Controllers\MessageSyncController;
use App\Http\Controllers\MessageDispatchController;
use App\Http\Controllers\SqlGeneratorController;
use App\Http\Controllers\Admin\AdminMetaController; use App\Http\Controllers\Admin\AdminMetaController;
use App\Http\Controllers\Admin\ConfigController; use App\Http\Controllers\Admin\ConfigController;
use App\Http\Controllers\Admin\ErpRequestReportConfigController;
use App\Http\Controllers\Admin\IpUserMappingController; use App\Http\Controllers\Admin\IpUserMappingController;
use App\Http\Controllers\Admin\JenkinsBuildController;
use App\Http\Controllers\Admin\JenkinsDeploymentController;
use App\Http\Controllers\Admin\OperationLogController; use App\Http\Controllers\Admin\OperationLogController;
use App\Http\Controllers\Admin\ProjectController; use App\Http\Controllers\Admin\ProjectController;
use App\Http\Controllers\Admin\ScheduledTaskController; use App\Http\Controllers\Admin\ScheduledTaskController;
use App\Http\Controllers\EnvController;
use App\Http\Controllers\JiraController;
use App\Http\Controllers\LogAnalysisController;
use App\Http\Controllers\MessageDispatchController;
use App\Http\Controllers\MessageSyncController;
use App\Http\Controllers\ProductionDiagnosisController;
use App\Http\Controllers\SqlGeneratorController;
use App\Http\Controllers\TestMailController;
use Illuminate\Support\Facades\Route;
// 环境管理API路由 // 环境管理API路由
Route::prefix('env')->group(function () { Route::prefix('env')->group(function () {
@@ -32,6 +37,12 @@ Route::prefix('env')->group(function () {
// SQL 生成器 API 路由 // SQL 生成器 API 路由
Route::prefix('sql-generator')->group(function () { Route::prefix('sql-generator')->group(function () {
Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']); Route::post('/ob-external-id/check', [SqlGeneratorController::class, 'checkObExternalId']);
Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']);
});
// 进产诊断 API 路由
Route::prefix('production-diagnosis')->middleware('throttle:30,1')->group(function () {
Route::post('/diagnose', [ProductionDiagnosisController::class, 'diagnose']);
}); });
// JIRA API路由 // JIRA API路由
@@ -42,6 +53,16 @@ Route::prefix('jira')->group(function () {
Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']); Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']);
}); });
// 提测邮件 API 路由
Route::prefix('test-mail')->group(function () {
Route::get('/sprints', [TestMailController::class, 'sprints']);
Route::get('/data', [TestMailController::class, 'data']);
Route::post('/databases', [TestMailController::class, 'databases']);
Route::post('/draft-sections', [TestMailController::class, 'draftSections']);
Route::post('/open-draft', [TestMailController::class, 'openDraft'])->middleware('admin.ip');
Route::post('/download', [TestMailController::class, 'download']);
});
// 消息同步API路由 // 消息同步API路由
Route::prefix('message-sync')->group(function () { Route::prefix('message-sync')->group(function () {
Route::post('/query', [MessageSyncController::class, 'queryMessages']); Route::post('/query', [MessageSyncController::class, 'queryMessages']);
@@ -70,6 +91,8 @@ Route::get('/admin/meta', [AdminMetaController::class, 'show']);
// 管理员IP白名单限定的后台接口 // 管理员IP白名单限定的后台接口
Route::prefix('admin')->middleware('admin.ip')->group(function () { Route::prefix('admin')->middleware('admin.ip')->group(function () {
Route::get('/erp-request-report/config', [ErpRequestReportConfigController::class, 'show']);
Route::put('/erp-request-report/config', [ErpRequestReportConfigController::class, 'update']);
Route::get('/configs', [ConfigController::class, 'index']); Route::get('/configs', [ConfigController::class, 'index']);
Route::post('/configs', [ConfigController::class, 'store']); Route::post('/configs', [ConfigController::class, 'store']);
Route::put('/configs/{config}', [ConfigController::class, 'update']); Route::put('/configs/{config}', [ConfigController::class, 'update']);
@@ -94,6 +117,14 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
// 定时任务管理 // 定时任务管理
Route::get('/scheduled-tasks', [ScheduledTaskController::class, 'index']); Route::get('/scheduled-tasks', [ScheduledTaskController::class, 'index']);
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']); Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
// Jenkins 发布历史
Route::get('/jenkins/build-projects', [JenkinsBuildController::class, 'projects']);
Route::post('/jenkins/trigger-builds', [JenkinsBuildController::class, 'trigger']);
Route::post('/jenkins/build-statuses', [JenkinsBuildController::class, 'statuses']);
Route::post('/jenkins/cancel-build', [JenkinsBuildController::class, 'cancel']);
Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']);
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
}); });
// 日志分析 API 路由 // 日志分析 API 路由
+38 -18
View File
@@ -23,36 +23,56 @@ Artisan::command('inspire', function () {
// Git Monitor - 每 10 分钟检查 release 分支 // Git Monitor - 每 10 分钟检查 release 分支
Schedule::command('git-monitor:check') Schedule::command('git-monitor:check')
->everyTenMinutes() ->everyTenMinutes()
->withoutOverlapping() ->withoutOverlapping(10)
->runInBackground() ->runInBackground()
->name('git-monitor-check') ->description('git-monitor-check')
->description('Git 监控 - 检查 release 分支变化') ->when(fn () => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check'));
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check'));
// Git Monitor - 每天凌晨 2 点刷新 release 缓存 // Git Monitor - 每天凌晨 2 点刷新 release 缓存
Schedule::command('git-monitor:cache') Schedule::command('git-monitor:cache')
->dailyAt('02:00') ->dailyAt('02:00')
->withoutOverlapping() ->withoutOverlapping()
->name('git-monitor-cache') ->description('git-monitor-cache')
->description('Git 监控 - 刷新 release 缓存') ->when(fn () => \App\Services\ScheduledTaskService::isEnabled('git-monitor-cache'));
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-cache'));
// SLS 日志分析 - 每天凌晨 2 点执行 // SLS 日志分析 - 每天凌晨 2 点执行(日志分析 + 代码分析)
Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or WARNING" --push') Schedule::command('log-analysis:run --from="-24h" --to="now" --query="content.level: ERROR" --mode=logs+code --push')
->dailyAt('02:00') ->dailyAt('02:00')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground() ->runInBackground()
->name('daily-log-analysis') ->description('daily-log-analysis')
->description('SLS 日志分析 - 每日分析过去 24 小时日志') ->when(fn () => \App\Services\ScheduledTaskService::isEnabled('daily-log-analysis'))
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('daily-log-analysis')) ->onFailure(fn () => Log::error('每日日志分析定时任务执行失败'));
->onFailure(fn() => Log::error('每日日志分析定时任务执行失败'));
// SLS 日志分析 - 每 4 小时执行一次 // SLS 日志分析 - 每 4 小时执行一次
Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WARNING" --push') Schedule::command('log-analysis:run --from="-6h" --to="now" --query="content.level: ERROR" --push')
->everyFourHours() ->everyFourHours()
->withoutOverlapping(60)
->runInBackground()
->description('frequent-log-analysis')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis'))
->onFailure(fn () => Log::error('SLS 日志分析定时任务执行失败'));
// Jenkins Monitor - 每分钟检查新构建
Schedule::command('jenkins:monitor')
->everyMinute()
->withoutOverlapping(10)
->runInBackground()
->description('jenkins-monitor')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('jenkins-monitor'));
// ERP OpenAPI 请求日报 - 每天早上 8 点统计前一天请求并发送钉钉
Schedule::command('erp-request-report:send')
->dailyAt('08:00')
->timezone('Asia/Shanghai')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground() ->runInBackground()
->name('frequent-log-analysis') ->description('erp-request-report')
->description('SLS 日志分析 - 定期分析过去 6 小时日志') ->when(fn () => \App\Services\ScheduledTaskService::isEnabled('erp-request-report'));
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis'))
->onFailure(fn() => Log::error('SLS 日志分析定时任务执行失败')); // 定时任务刷新 - 每天凌晨 3 点刷新定时任务列表
Schedule::command('scheduled-task:refresh')
->dailyAt('03:00')
->withoutOverlapping()
->description('scheduled-task-refresh')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('scheduled-task-refresh'));
+5 -1
View File
@@ -1,7 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AdminController; use App\Http\Controllers\AdminController;
use App\Http\Controllers\ProductionDiagnosisPageController;
use Illuminate\Support\Facades\Route;
// 首页 - 显示admin框架 // 首页 - 显示admin框架
Route::get('/', [AdminController::class, 'index'])->name('home'); Route::get('/', [AdminController::class, 'index'])->name('home');
@@ -9,8 +10,10 @@ Route::get('/', [AdminController::class, 'index'])->name('home');
// 前端路由 - 所有页面都通过admin框架显示 // 前端路由 - 所有页面都通过admin框架显示
Route::get('/env', [AdminController::class, 'index'])->name('admin.env'); Route::get('/env', [AdminController::class, 'index'])->name('admin.env');
Route::get('/sql-generator', [AdminController::class, 'index'])->name('admin.sql-generator'); Route::get('/sql-generator', [AdminController::class, 'index'])->name('admin.sql-generator');
Route::get('/production-diagnosis', ProductionDiagnosisPageController::class)->name('admin.production-diagnosis');
Route::get('/weekly-report', [AdminController::class, 'index'])->name('admin.weekly-report'); Route::get('/weekly-report', [AdminController::class, 'index'])->name('admin.weekly-report');
Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog'); Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog');
Route::get('/test-mail', [AdminController::class, 'index'])->name('admin.test-mail');
Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync'); Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync');
Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-sync'); Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-sync');
Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch'); Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch');
@@ -20,3 +23,4 @@ Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs');
Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip'); Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');
Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip'); Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip');
Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip'); Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip');
Route::get('/jenkins-builds', [AdminController::class, 'index'])->name('admin.jenkins-builds')->middleware('admin.ip');
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
class HostAccessTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['toolbox.admin_host' => 'toolbox.local']);
}
public function test_admin_host_can_open_the_toolbox_menu(): void
{
$response = $this->get('http://toolbox.local/');
$response->assertOk();
$response->assertSee('<admin-dashboard>', false);
}
public function test_ip_host_gets_the_standalone_production_diagnosis_page(): void
{
$response = $this->get('http://192.168.1.20/production-diagnosis');
$response->assertOk();
$response->assertSee('<production-diagnosis>', false);
$response->assertDontSee('<admin-dashboard>', false);
}
public function test_ip_host_cannot_open_other_toolbox_pages(): void
{
$this
->get('http://192.168.1.20/')
->assertNotFound();
$this
->get('http://192.168.1.20/env')
->assertNotFound();
$this
->get('http://192.168.1.20/settings')
->assertNotFound();
}
public function test_ip_host_can_only_call_the_production_diagnosis_api(): void
{
$this
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertUnprocessable();
$this
->getJson('http://192.168.1.20/api/admin/meta')
->assertNotFound();
}
public function test_unconfigured_hostname_is_rejected(): void
{
$this
->get('http://attacker.example/production-diagnosis')
->assertNotFound();
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature;
use App\Models\OperationLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class IpUserMappingTest extends TestCase
{
use RefreshDatabase;
public function test_store_syncs_missing_operation_log_user_labels(): void
{
config(['toolbox.admin_ips' => ['127.0.0.1']]);
$missingLabelLog = OperationLog::query()->create($this->operationLogData([
'ip_address' => '192.168.1.10',
'user_label' => null,
]));
$emptyLabelLog = OperationLog::query()->create($this->operationLogData([
'ip_address' => '192.168.1.10',
'user_label' => '',
]));
$existingLabelLog = OperationLog::query()->create($this->operationLogData([
'ip_address' => '192.168.1.10',
'user_label' => 'lisi',
]));
$otherIpLog = OperationLog::query()->create($this->operationLogData([
'ip_address' => '192.168.1.11',
'user_label' => null,
]));
$response = $this->postJson('/api/admin/ip-user-mappings', [
'ip_address' => '192.168.1.10',
'user_name' => 'zhangsan',
'remark' => 'dev',
]);
$response->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('data.synced_operation_logs_count', 2);
$this->assertSame('zhangsan', $missingLabelLog->refresh()->user_label);
$this->assertSame('zhangsan', $emptyLabelLog->refresh()->user_label);
$this->assertSame('lisi', $existingLabelLog->refresh()->user_label);
$this->assertNull($otherIpLog->refresh()->user_label);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function operationLogData(array $overrides = []): array
{
return array_merge([
'ip_address' => '127.0.0.1',
'user_label' => null,
'method' => 'POST',
'path' => '/api/test',
'route_name' => null,
'status_code' => 200,
'duration_ms' => 12,
'request_payload' => [],
'user_agent' => 'PHPUnit',
], $overrides);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Tests\Feature;
use App\Services\ProductionDiagnosisService;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class ProductionDiagnosisTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['toolbox.admin_host' => 'toolbox.local']);
}
public function test_validation_errors_are_returned_to_ip_clients(): void
{
$this
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertUnprocessable()
->assertJson([
'success' => false,
'message' => '请求参数验证失败',
]);
}
public function test_successful_diagnosis_response_is_unchanged(): void
{
$result = [
'type' => 'case',
'type_label' => '病例',
'code' => 'C123',
'found' => true,
'entity' => ['patient_name' => '测试患者'],
'checks' => [],
'can_production' => true,
];
$service = Mockery::mock(ProductionDiagnosisService::class);
$service->shouldReceive('diagnose')
->once()
->with('case', 'C123')
->andReturn($result);
$this->app->instance(ProductionDiagnosisService::class, $service);
$this
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [
'type' => 'case',
'code' => ' C123 ',
])
->assertOk()
->assertExactJson([
'success' => true,
'data' => $result,
]);
}
public function test_internal_exception_details_are_not_returned(): void
{
$service = Mockery::mock(ProductionDiagnosisService::class);
$service->shouldReceive('diagnose')
->once()
->andThrow(new RuntimeException('SQLSTATE[HY000] secret database detail'));
$this->app->instance(ProductionDiagnosisService::class, $service);
$response = $this->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [
'type' => 'case',
'code' => 'C123',
]);
$response
->assertInternalServerError()
->assertExactJson([
'success' => false,
'message' => '诊断服务暂不可用,请稍后重试',
]);
$response->assertDontSee('SQLSTATE');
$response->assertDontSee('secret database detail');
}
public function test_ip_client_is_rate_limited_after_thirty_requests_per_minute(): void
{
for ($attempt = 1; $attempt <= 30; $attempt++) {
$this
->withServerVariables(['REMOTE_ADDR' => '192.168.1.50'])
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertUnprocessable();
}
$this
->withServerVariables(['REMOTE_ADDR' => '192.168.1.50'])
->postJson('http://192.168.1.20/api/production-diagnosis/diagnose', [])
->assertTooManyRequests();
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\DB;
use Mockery;
use Tests\TestCase;
class SqlGeneratorTest extends TestCase
{
public function test_check_production_countries_requires_production_codes(): void
{
$response = $this->postJson('/api/sql-generator/production-countries/check', []);
$response->assertStatus(422);
$response->assertJson([
'success' => false,
'message' => '请求参数验证失败',
]);
}
public function test_check_production_countries_returns_country_codes_from_crm(): void
{
$connection = Mockery::mock();
DB::shouldReceive('connection')
->times(2)
->with('crmslave')
->andReturn($connection);
$productionBuilder = Mockery::mock();
$productionBuilder->shouldReceive('join')
->once()
->with('ea_production_cstm as epc', 'ep.id', '=', 'epc.id_c')
->andReturnSelf();
$productionBuilder->shouldReceive('where')
->once()
->with('ep.deleted', 0)
->andReturnSelf();
$productionBuilder->shouldReceive('whereIn')
->once()
->with('ep.name', ['M20260508006905'])
->andReturnSelf();
$productionBuilder->shouldReceive('select')
->once()
->with([
'ep.name as production_code',
'epc.ea_case_id_c',
'epc.ea_businessorder_id_c',
'epc.ea_salesorder_id_c',
])
->andReturnSelf();
$productionBuilder->shouldReceive('get')
->once()
->andReturn(collect([
(object) [
'production_code' => 'M20260508006905',
'ea_case_id_c' => '',
'ea_businessorder_id_c' => '',
'ea_salesorder_id_c' => 'sales-order-1',
],
]));
$salesOrderBuilder = Mockery::mock();
$salesOrderBuilder->shouldReceive('join')
->once()
->with('accounts_ea_salesorder_1_c as aes1c', Mockery::type('Closure'))
->andReturnSelf();
$salesOrderBuilder->shouldReceive('join')
->once()
->with('accounts as a_base', 'a_base.id', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->andReturnSelf();
$salesOrderBuilder->shouldReceive('join')
->once()
->with('accounts_cstm as ac', 'ac.id_c', '=', 'aes1c.accounts_ea_salesorder_1accounts_ida')
->andReturnSelf();
$salesOrderBuilder->shouldReceive('where')
->once()
->with('es.deleted', 0)
->andReturnSelf();
$salesOrderBuilder->shouldReceive('where')
->once()
->with('a_base.deleted', 0)
->andReturnSelf();
$salesOrderBuilder->shouldReceive('whereIn')
->once()
->with('es.id', ['sales-order-1'])
->andReturnSelf();
$salesOrderBuilder->shouldReceive('whereNotNull')
->once()
->with('ac.country_c')
->andReturnSelf();
$salesOrderBuilder->shouldReceive('select')
->once()
->with([
'es.id as entity_id',
'ac.country_c as country',
'ac.province_c as province',
])
->andReturnSelf();
$salesOrderBuilder->shouldReceive('get')
->once()
->andReturn(collect([
(object) [
'entity_id' => 'sales-order-1',
'country' => '840',
'province' => '',
],
]));
$connection->shouldReceive('table')
->once()
->with('ea_production as ep')
->andReturn($productionBuilder);
$connection->shouldReceive('table')
->once()
->with('ea_salesorder as es')
->andReturn($salesOrderBuilder);
$response = $this->postJson('/api/sql-generator/production-countries/check', [
'production_codes' => ['M20260508006905'],
]);
$response->assertStatus(200);
$response->assertJson([
'success' => true,
'data' => [
'production_countries' => [
'M20260508006905' => ['US'],
],
],
]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Tests\Unit;
use App\Enums\CaseLabelBit;
use PHPUnit\Framework\TestCase;
class CaseLabelBitTest extends TestCase
{
public function test_split_returns_each_set_bit(): void
{
$this->assertSame([], CaseLabelBit::split(0));
$this->assertSame([2], CaseLabelBit::split(2));
$this->assertSame([2, 4], CaseLabelBit::split(6));
$this->assertSame([1, 2, 4, 8], CaseLabelBit::split(15));
}
public function test_to_text_describes_stuck_reasons(): void
{
$this->assertSame('无标记', CaseLabelBit::toText(0));
$this->assertSame('新病例订单卡生产', CaseLabelBit::toText(CaseLabelBit::APPLIANCE_NEED_MONEY));
$this->assertSame(
'新病例订单卡生产 / 产品变更卡生产',
CaseLabelBit::toText(CaseLabelBit::APPLIANCE_NEED_MONEY | CaseLabelBit::UPGRADE_NEED_MONEY)
);
}
public function test_unknown_bit_falls_back_to_generic_label(): void
{
$this->assertSame('未知标记位 16', CaseLabelBit::crmLabel(16));
$this->assertStringContainsString('未在 CRM 枚举中定义', CaseLabelBit::description(16));
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Tests\Unit;
use App\Services\DingTalkService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class DingTalkServiceTest extends TestCase
{
public function test_it_sends_text_to_a_robot_token(): void
{
Http::fake([
'https://oapi.dingtalk.com/robot/send?access_token=report-token' => Http::response([
'errcode' => 0,
]),
]);
$sent = (new DingTalkService)->sendTextToToken('report-token', '日报内容');
$this->assertTrue($sent);
Http::assertSent(fn ($request) => $request->url() === 'https://oapi.dingtalk.com/robot/send?access_token=report-token'
&& $request->data() === [
'msgtype' => 'text',
'text' => ['content' => '日报内容'],
'at' => ['atMobiles' => [], 'isAtAll' => false],
]);
}
public function test_it_reports_a_dingtalk_business_error(): void
{
Http::fake([
'https://oapi.dingtalk.com/robot/send?access_token=invalid-token' => Http::response([
'errcode' => 310000,
'errmsg' => 'invalid token',
]),
]);
$sent = (new DingTalkService)->sendTextToToken('invalid-token', '日报内容');
$this->assertFalse($sent);
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
namespace Tests\Unit;
use App\Services\ConfigService;
use App\Services\DingTalkService;
use App\Services\ErpRequestReportService;
use Carbon\CarbonImmutable;
use Illuminate\Database\Connection;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use InvalidArgumentException;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class ErpRequestReportServiceTest extends TestCase
{
public function test_it_defaults_to_the_previous_calendar_day(): void
{
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-02 16:30:00', 'UTC'));
try {
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-02 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-03 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect());
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-02 ERP OpenAPI 请求统计\n无请求记录")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))->sendReport();
$this->assertSame('2026-08-02', $result['date']);
$this->assertSame('2026-08-02 00:00:00', $result['from']);
$this->assertSame('2026-08-02 23:59:59', $result['to']);
} finally {
CarbonImmutable::setTestNow();
}
}
public function test_it_sends_the_previous_days_erp_requests_grouped_by_company_and_uri(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->with("agents.name as agent_name, agents.code as agent_code, SUBSTRING_INDEX(request_records.request_uri, '?', 1) as request_uri, COUNT(*) as request_count")->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->with('agents', 'agents.id', '=', 'request_records.user_id')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-02 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-03 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->with("agents.id, agents.name, agents.code, SUBSTRING_INDEX(request_records.request_uri, '?', 1)")->andReturnSelf();
$query->shouldReceive('orderBy')->once()->with('agents.name')->andReturnSelf();
$query->shouldReceive('orderBy')->once()->with('agents.code')->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->with("SUBSTRING_INDEX(request_records.request_uri, '?', 1)")->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect([
(object) [
'agent_name' => '广州医路精密医疗器械有限公司',
'agent_code' => 'G201704010003',
'request_uri' => '/openapi/erp/deliveries',
'request_count' => 2,
],
(object) [
'agent_name' => '广州医路精密医疗器械有限公司',
'agent_code' => 'G201704010003',
'request_uri' => '/openapi/erp/orders?access_token=secret',
'request_count' => 3,
],
]));
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-02 ERP OpenAPI 请求统计\n\n广州医路精密医疗器械有限公司 G201704010003\n/openapi/erp/deliveries 2次\n/openapi/erp/orders 3次")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport('2026-08-02');
$this->assertSame('2026-08-02', $result['date']);
$this->assertSame(5, $result['request_count']);
$this->assertSame(1, $result['company_count']);
}
public function test_it_supports_inclusive_date_ranges(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-01 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-08 00:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect());
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-01 ~ 2026-08-07 ERP OpenAPI 请求统计\n无请求记录")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport(null, '2026-08-01', '2026-08-07');
$this->assertSame('2026-08-01 ~ 2026-08-07', $result['date']);
$this->assertSame('2026-08-01 00:00:00', $result['from']);
$this->assertSame('2026-08-07 23:59:59', $result['to']);
}
public function test_it_supports_inclusive_datetime_ranges(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '>=', '2026-08-02 08:00:00')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.created', '<', '2026-08-02 18:00:01')->andReturnSelf();
$query->shouldReceive('where')->once()->with('request_records.request_uri', 'like', '/openapi/erp/%')->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect());
$dingTalkService->shouldReceive('sendTextToToken')
->once()
->with('report-token', "2026-08-02 08:00:00 ~ 2026-08-02 18:00:00 ERP OpenAPI 请求统计\n无请求记录")
->andReturnTrue();
$result = (new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport(null, '2026-08-02 08:00:00', '2026-08-02 18:00:00');
$this->assertSame('2026-08-02 08:00:00 ~ 2026-08-02 18:00:00', $result['date']);
}
public function test_it_rejects_date_mixed_with_from_to(): void
{
$database = Mockery::mock(DatabaseManager::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldNotReceive('connection');
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('--date 不能与 --from/--to 同时使用');
(new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport('2026-08-02', '2026-08-01', '2026-08-07');
}
public function test_it_requires_a_configured_dingtalk_token_before_querying(): void
{
$database = Mockery::mock(DatabaseManager::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn(null);
$database->shouldNotReceive('connection');
$dingTalkService->shouldNotReceive('sendTextToToken');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('未配置 ERP 请求日报的钉钉机器人 Token');
(new ErpRequestReportService($database, $dingTalkService, $configService))
->sendReport('2026-08-02');
}
public function test_it_splits_large_reports_into_safe_dingtalk_messages(): void
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$dingTalkService = Mockery::mock(DingTalkService::class);
$configService = Mockery::mock(ConfigService::class);
$sentMessages = [];
$configService->shouldReceive('get')
->once()
->with(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
->andReturn('report-token');
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('request_records')->andReturn($query);
$query->shouldReceive('selectRaw')->once()->andReturnSelf();
$query->shouldReceive('leftJoin')->once()->andReturnSelf();
$query->shouldReceive('where')->times(3)->andReturnSelf();
$query->shouldReceive('groupByRaw')->once()->andReturnSelf();
$query->shouldReceive('orderBy')->twice()->andReturnSelf();
$query->shouldReceive('orderByRaw')->once()->andReturnSelf();
$query->shouldReceive('get')->once()->andReturn(collect(range(1, 300))->map(
fn (int $index) => (object) [
'agent_name' => '广州医路精密医疗器械有限公司',
'agent_code' => 'G201704010003',
'request_uri' => '/openapi/erp/'.str_pad((string) $index, 100, 'x'),
'request_count' => 1,
]
));
$dingTalkService->shouldReceive('sendTextToToken')
->atLeast()->once()
->withArgs(function (string $token, string $message) use (&$sentMessages): bool {
$sentMessages[] = $message;
return $token === 'report-token';
})
->andReturnTrue();
(new ErpRequestReportService($database, $dingTalkService, $configService))->sendReport('2026-08-02');
$this->assertGreaterThan(1, count($sentMessages));
$this->assertContainsOnly('string', $sentMessages);
$this->assertTrue(collect($sentMessages)->every(fn (string $message) => strlen($message) <= 18_000));
$this->assertStringContainsString('/openapi/erp/'.str_pad('300', 100, 'x').' 1次', implode("\n", $sentMessages));
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Unit;
use App\Services\DingTalkService;
use App\Services\GitMonitorService;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class GitMonitorServiceTest extends TestCase
{
private array $tempPaths = [];
protected function tearDown(): void
{
foreach (array_reverse($this->tempPaths) as $path) {
if (is_dir($path)) {
$this->removeDirectory($path);
}
}
parent::tearDown();
}
public function test_auto_creating_release_branch_keeps_working_tree_untouched(): void
{
$workspacePath = $this->makeTempDirectory('toolbox-git-workspace-');
$remotePath = $this->makeTempDirectory('toolbox-git-remote-');
$repoPath = $workspacePath.DIRECTORY_SEPARATOR.'demo-repo';
$this->git($remotePath, ['git', 'init', '--bare']);
$this->git($workspacePath, ['git', 'clone', $remotePath, 'demo-repo']);
$this->git($repoPath, ['git', 'config', 'user.email', 'test@example.com']);
$this->git($repoPath, ['git', 'config', 'user.name', 'Test User']);
file_put_contents($repoPath.DIRECTORY_SEPARATOR.'version.txt', '1.0.0');
$this->git($repoPath, ['git', 'add', 'version.txt']);
$this->git($repoPath, ['git', 'commit', '-m', 'initial']);
$this->git($repoPath, ['git', 'branch', '-M', 'master']);
$this->git($repoPath, ['git', 'push', '-u', 'origin', 'master']);
$this->git($repoPath, ['git', 'checkout', '-b', 'develop']);
file_put_contents($repoPath.DIRECTORY_SEPARATOR.'work.txt', "draft\n");
$service = $this->makeGitMonitorService($workspacePath);
$method = new \ReflectionMethod($service, 'ensureReleaseBranchExists');
$method->invoke($service, 'demo-repo', ['directory' => 'demo-repo'], 'release/1.1.0', 'next release');
$this->assertSame('develop', $this->git($repoPath, ['git', 'branch', '--show-current']));
$this->assertSame("draft\n", file_get_contents($repoPath.DIRECTORY_SEPARATOR.'work.txt'));
$this->assertStringContainsString('?? work.txt', $this->git($repoPath, ['git', 'status', '--short']));
$this->assertNotEmpty($this->git($repoPath, ['git', 'ls-remote', '--heads', 'origin', 'release/1.1.0']));
$this->assertSame('1.1.0', $this->git($repoPath, ['git', 'show', 'origin/release/1.1.0:version.txt']));
}
private function makeGitMonitorService(string $workspacePath): GitMonitorService
{
$reflection = new \ReflectionClass(GitMonitorService::class);
$service = $reflection->newInstanceWithoutConstructor();
$this->setProperty($service, 'projectsPath', $workspacePath);
$this->setProperty($service, 'gitTimeout', 60);
$this->setProperty($service, 'dingTalkService', $this->createMock(DingTalkService::class));
return $service;
}
private function setProperty(object $object, string $name, mixed $value): void
{
$property = new \ReflectionProperty($object, $name);
$property->setValue($object, $value);
}
private function makeTempDirectory(string $prefix): string
{
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.$prefix.bin2hex(random_bytes(6));
mkdir($path, 0777, true);
$this->tempPaths[] = $path;
return $path;
}
private function git(string $workingDirectory, array $command): string
{
$process = new Process($command, $workingDirectory);
$process->setTimeout(60);
$process->mustRun();
return trim($process->getOutput());
}
private function removeDirectory(string $path): void
{
$items = scandir($path);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$itemPath = $path.DIRECTORY_SEPARATOR.$item;
if (is_dir($itemPath) && ! is_link($itemPath)) {
$this->removeDirectory($itemPath);
continue;
}
unlink($itemPath);
}
rmdir($path);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Tests\Unit;
use App\Clients\JenkinsClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class JenkinsClientTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'jenkins.host' => 'https://jenkins.example.com',
'jenkins.username' => 'test-user',
'jenkins.api_token' => 'test-token',
'jenkins.timeout' => 30,
]);
}
public function test_parameterized_build_posts_form_json_parameters_and_returns_queue_url(): void
{
Http::fake([
'https://jenkins.example.com/job/deploy/api/json' => Http::response([
'nextBuildNumber' => 42,
]),
'https://jenkins.example.com/crumbIssuer/api/json' => Http::response([
'crumbRequestField' => 'Jenkins-Crumb',
'crumb' => 'test-crumb',
]),
'https://jenkins.example.com/job/deploy/build?delay=0sec' => Http::response('', 201, [
'Location' => 'https://jenkins.example.com/queue/item/123/',
]),
]);
$result = app(JenkinsClient::class)->triggerBuild('deploy', [
'project' => 'portal',
'branchName' => 'release/1.0',
]);
$this->assertTrue($result['success']);
$this->assertSame('https://jenkins.example.com/queue/item/123/', $result['queue_url']);
$this->assertSame(42, $result['build_number']);
Http::assertSent(function ($request) {
parse_str($request->body(), $form);
$payload = json_decode((string) ($form['json'] ?? ''), true);
$parameters = collect($payload['parameter'] ?? [])->pluck('value', 'name');
return $request->method() === 'POST'
&& $request->url() === 'https://jenkins.example.com/job/deploy/build?delay=0sec'
&& $parameters['project'] === 'portal'
&& $parameters['branchName'] === 'release/1.0';
});
}
public function test_cancel_build_with_queue_url_cancels_pending_queue_item(): void
{
Http::fake([
'https://jenkins.example.com/queue/item/123/api/json' => Http::response([
'why' => 'In the quiet period',
]),
'https://jenkins.example.com/crumbIssuer/api/json' => Http::response([
'crumbRequestField' => 'Jenkins-Crumb',
'crumb' => 'test-crumb',
]),
'https://jenkins.example.com/queue/cancelItem?id=123' => Http::response('', 200),
]);
$result = app(JenkinsClient::class)->cancelBuild('deploy', 'https://jenkins.example.com/queue/item/123/');
$this->assertTrue($result['success']);
$this->assertTrue($result['cancelled_queue']);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://jenkins.example.com/queue/cancelItem?id=123';
});
}
public function test_cancel_build_without_queue_url_cancels_matching_queued_job(): void
{
Http::fake([
'https://jenkins.example.com/queue/api/json' => Http::response([
'items' => [
[
'id' => 456,
'task' => [
'fullName' => 'deploy',
'name' => 'deploy',
],
],
],
]),
'https://jenkins.example.com/crumbIssuer/api/json' => Http::response([
'crumbRequestField' => 'Jenkins-Crumb',
'crumb' => 'test-crumb',
]),
'https://jenkins.example.com/queue/cancelItem?id=456' => Http::response('', 200),
]);
$result = app(JenkinsClient::class)->cancelBuild('deploy', null, 42);
$this->assertTrue($result['success']);
$this->assertTrue($result['cancelled_queue']);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://jenkins.example.com/queue/cancelItem?id=456';
});
}
}
+277 -23
View File
@@ -2,9 +2,11 @@
namespace Tests\Unit; namespace Tests\Unit;
use Tests\TestCase;
use App\Services\JiraService; use App\Services\JiraService;
use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use JiraRestApi\Project\ProjectService;
use Tests\TestCase;
class JiraServiceTest extends TestCase class JiraServiceTest extends TestCase
{ {
@@ -19,12 +21,19 @@ class JiraServiceTest extends TestCase
'jira.host' => 'https://test-jira.example.com', 'jira.host' => 'https://test-jira.example.com',
'jira.username' => 'test-user', 'jira.username' => 'test-user',
'jira.password' => 'test-password', 'jira.password' => 'test-password',
'jira.default_user' => 'test-user' 'jira.default_user' => 'test-user',
]); ]);
$this->jiraService = app(JiraService::class); $this->jiraService = app(JiraService::class);
} }
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_is_task_completed_returns_false_for_incomplete_statuses() public function test_is_task_completed_returns_false_for_incomplete_statuses()
{ {
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
@@ -37,7 +46,7 @@ class JiraServiceTest extends TestCase
'需求已评审', '需求已评审',
'In Progress', 'In Progress',
'To Do', 'To Do',
'Open' 'Open',
]; ];
foreach ($incompleteStatuses as $status) { foreach ($incompleteStatuses as $status) {
@@ -59,7 +68,7 @@ class JiraServiceTest extends TestCase
'Closed', 'Closed',
'Resolved', 'Resolved',
'已完成', '已完成',
'Complete' 'Complete',
]; ];
foreach ($completeStatuses as $status) { foreach ($completeStatuses as $status) {
@@ -76,10 +85,11 @@ class JiraServiceTest extends TestCase
$method = $reflection->getMethod('organizeTasksForReport'); $method = $reflection->getMethod('organizeTasksForReport');
$emptyWorkLogs = collect(); $emptyWorkLogs = collect();
$result = $method->invoke($this->jiraService, $emptyWorkLogs); $result = $method->invoke($this->jiraService, $emptyWorkLogs, 'test-user');
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
$this->assertTrue($result->has('sprints')); $this->assertTrue($result->has('sprints'));
$this->assertTrue($result->has('stories'));
$this->assertTrue($result->has('tasks')); $this->assertTrue($result->has('tasks'));
$this->assertTrue($result->has('bugs')); $this->assertTrue($result->has('bugs'));
} }
@@ -100,10 +110,10 @@ class JiraServiceTest extends TestCase
'bug_stage' => null, 'bug_stage' => null,
'bug_type' => null, 'bug_type' => null,
'parent_task' => null, 'parent_task' => null,
] ],
]); ]);
$result = $method->invoke($this->jiraService, $workLogs); $result = $method->invoke($this->jiraService, $workLogs, 'test-user');
$this->assertTrue($result['sprints']->has('十月中需求')); $this->assertTrue($result['sprints']->has('十月中需求'));
$this->assertCount(1, $result['sprints']['十月中需求']); $this->assertCount(1, $result['sprints']['十月中需求']);
@@ -124,43 +134,184 @@ class JiraServiceTest extends TestCase
'sprint' => null, 'sprint' => null,
'bug_stage' => 'SIT环境BUG', 'bug_stage' => 'SIT环境BUG',
'bug_type' => '需求未说明', 'bug_type' => '需求未说明',
'bug_description' => null,
'parent_task' => null, 'parent_task' => null,
] 'assignee' => 'test-user',
'developer' => null,
'actual_fixer' => null,
],
]); ]);
$result = $method->invoke($this->jiraService, $workLogs); $result = $method->invoke($this->jiraService, $workLogs, 'test-user');
$this->assertTrue($result['bugs']->has('SIT环境BUG')); $this->assertTrue($result['bugs']->has('SIT环境BUG'));
$this->assertCount(1, $result['bugs']['SIT环境BUG']); $this->assertCount(1, $result['bugs']['SIT环境BUG']);
$this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']); $this->assertEquals('需求未说明', $result['bugs']['SIT环境BUG'][0]['bug_type']);
} }
public function test_resolve_weekly_report_range_for_last_week()
{
Carbon::setTestNow('2026-04-02 10:00:00');
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('resolveWeeklyReportRange');
$result = $method->invoke($this->jiraService, 'last_week');
$this->assertEquals('2026-03-23 00:00:00', $result['start']->format('Y-m-d H:i:s'));
$this->assertEquals('2026-03-29 23:59:59', $result['end']->format('Y-m-d H:i:s'));
$this->assertEquals('上周完成的任务', $result['title']);
}
public function test_resolve_weekly_report_range_for_this_week()
{
Carbon::setTestNow('2026-04-02 10:00:00');
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('resolveWeeklyReportRange');
$result = $method->invoke($this->jiraService, 'this_week');
$this->assertEquals('2026-03-30 00:00:00', $result['start']->format('Y-m-d H:i:s'));
$this->assertEquals('2026-04-02 23:59:59', $result['end']->format('Y-m-d H:i:s'));
$this->assertEquals('本周完成的任务', $result['title']);
}
public function test_build_next_week_tasks_jql_includes_developer_owner_for_requirements()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('buildNextWeekTasksJql');
$result = $method->invoke($this->jiraService, 'test-user', ['需求已评审', '需求已排期', '开发中'], 'cf[11000]');
$this->assertEquals(
'(assignee = "test-user" OR cf[11000] = "test-user") AND status IN ("需求已评审", "需求已排期", "开发中") AND issuetype in ("Story", "需求") AND Sprint is not EMPTY ORDER BY created ASC',
$result
);
}
public function test_build_next_week_tasks_jql_keeps_assignee_only_when_owner_field_missing()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('buildNextWeekTasksJql');
$result = $method->invoke($this->jiraService, 'test-user', ['需求已评审', '需求已排期', '开发中'], null);
$this->assertEquals(
'assignee = "test-user" AND status IN ("需求已评审", "需求已排期", "开发中") AND issuetype in ("Story", "需求") AND Sprint is not EMPTY ORDER BY created ASC',
$result
);
}
public function test_extract_sprint_info_from_string() public function test_extract_sprint_info_from_string()
{ {
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractSprintInfo'); $method = $reflection->getMethod('extractSprintInfo');
$issue = (object)[ $issue = (object) [
'fields' => (object)[ 'fields' => (object) [
'customfield_10020' => [ 'customFields' => [
'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]' 'customfield_10004' => [
] 'com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,state=ACTIVE]',
] ],
],
],
]; ];
$result = $method->invoke($this->jiraService, $issue); $result = $method->invoke($this->jiraService, $issue);
$this->assertEquals('十月中需求', $result); $this->assertEquals('十月中需求', $result);
} }
public function test_resolve_test_mail_sprint_period_normalizes_sprint_name()
{
$result = $this->jiraService->resolveTestMailSprintPeriod('CRM2603中迭代');
$this->assertEquals('Sprint2603月中', $result);
}
public function test_resolve_test_mail_sprint_period_uses_issue_sprint_name()
{
$issues = collect([
['sprint' => 'CRM2605底迭代'],
]);
$result = $this->jiraService->resolveTestMailSprintPeriod('2324', $issues);
$this->assertEquals('Sprint2605月底', $result);
}
public function test_resolve_test_mail_sprint_period_handles_year_month_name()
{
$result = $this->jiraService->resolveTestMailSprintPeriod('2026年5月中需求');
$this->assertEquals('Sprint2605月中', $result);
}
public function test_estimated_test_at_field_name_does_not_match_actual_test_at()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('isEstimatedTestAtFieldName');
$this->assertFalse($method->invoke($this->jiraService, '实际提测时间'));
$this->assertTrue($method->invoke($this->jiraService, '预计提测时间'));
}
public function test_next_test_mail_version_increments_second_segment()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('nextTestMailVersion');
$this->assertEquals('2.70.0.0', $method->invoke($this->jiraService, '2.69.0.0'));
}
public function test_upcoming_release_version_falls_back_to_next_minor_when_jira_versions_are_not_maintained()
{
$projectService = $this->createMock(ProjectService::class);
$projectService->method('getVersions')->with('TP')->willReturn(new \ArrayObject([
(object) ['name' => '1.34.0.0', 'released' => false],
(object) ['name' => '1.37.0.0', 'released' => false],
]));
$reflection = new \ReflectionClass($this->jiraService);
$property = $reflection->getProperty('projectService');
$property->setValue($this->jiraService, $projectService);
$this->assertSame([
'version' => '1.46.0.0',
'description' => null,
'release_date' => null,
], $this->jiraService->getUpcomingReleaseVersion('TP', '1.45.0.0'));
}
public function test_test_mail_template_defaults_use_next_versions()
{
$defaults = $this->jiraService->getTestMailTemplateDefaults();
$this->assertEquals('2.70.0.0', $defaults['container_groups']['agent']['default_version']);
$this->assertEquals('2.65.0.0', $defaults['container_groups']['portal']['default_version']);
$this->assertEquals('1.43.0.0', $defaults['container_groups']['portal-ticket']['default_version']);
$this->assertEquals('1.12.0.0', $defaults['container_groups']['mono']['default_version']);
$this->assertSame('mono', array_key_last($defaults['container_groups']));
$this->assertEquals(
['portal-mono-be-aplct', 'portal-mono-be-web'],
array_column($defaults['container_groups']['mono']['containers'], 'name')
);
$this->assertEquals(
['中国', '中国'],
array_column($defaults['container_groups']['mono']['containers'], 'location')
);
$this->assertFalse($defaults['container_groups']['mono']['database_enabled']);
}
public function test_extract_bug_stage_from_labels() public function test_extract_bug_stage_from_labels()
{ {
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractBugStage'); $method = $reflection->getMethod('extractBugStage');
$issue = (object)[ $issue = (object) [
'fields' => (object)[ 'fields' => (object) [
'labels' => ['SIT', 'bug'] 'labels' => ['SIT', 'bug'],
] ],
]; ];
$result = $method->invoke($this->jiraService, $issue); $result = $method->invoke($this->jiraService, $issue);
@@ -172,13 +323,116 @@ class JiraServiceTest extends TestCase
$reflection = new \ReflectionClass($this->jiraService); $reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractBugType'); $method = $reflection->getMethod('extractBugType');
$issue = (object)[ $issue = (object) [
'fields' => (object)[ 'fields' => (object) [
'labels' => ['需求未说明', 'bug'] 'labels' => ['需求未说明', 'bug'],
] ],
]; ];
$result = $method->invoke($this->jiraService, $issue); $result = $method->invoke($this->jiraService, $issue);
$this->assertEquals('需求未说明', $result); $this->assertEquals('需求未说明', $result);
} }
public function test_extract_developer_from_user_object()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractDeveloper');
$issue = (object) [
'fields' => (object) [
'customFields' => [
'customfield_11000' => (object) [
'name' => 'zhangsan',
'displayName' => '张三',
'emailAddress' => 'zhangsan@example.com',
],
],
],
];
$this->assertEquals('zhangsan', $method->invoke($this->jiraService, $issue));
}
public function test_extract_developer_from_associative_array()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractDeveloper');
$issue = (object) [
'fields' => (object) [
'customFields' => [
'customfield_11000' => [
'name' => 'lisi',
'displayName' => '李四',
],
],
],
];
$this->assertEquals('lisi', $method->invoke($this->jiraService, $issue));
}
public function test_extract_actual_fixer_from_user_object()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractActualFixer');
$issue = (object) [
'fields' => (object) [
'customFields' => [
'customfield_11301' => (object) [
'name' => 'wangwu',
'displayName' => '王五',
],
],
],
];
$this->assertEquals('wangwu', $method->invoke($this->jiraService, $issue));
}
public function test_extract_developer_returns_null_when_field_missing()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('extractDeveloper');
$issue = (object) [
'fields' => (object) [
'customFields' => [],
],
];
$this->assertNull($method->invoke($this->jiraService, $issue));
}
public function test_organize_tasks_for_report_includes_bug_when_user_is_developer_only()
{
$reflection = new \ReflectionClass($this->jiraService);
$method = $reflection->getMethod('organizeTasksForReport');
// 模拟 WP-7158 场景:经办人是测试同学,开发人才是当前用户
$workLogs = collect([
[
'issue_key' => 'WP-7158',
'issue_summary' => '生产 Bug 修复',
'issue_url' => 'https://test-jira.example.com/browse/WP-7158',
'issue_status' => 'Done',
'issue_type' => 'Bug',
'sprint' => null,
'bug_stage' => '生产环境BUG',
'bug_type' => '代码错误',
'bug_description' => null,
'parent_task' => null,
'assignee' => 'tester-user',
'developer' => 'test-user',
'actual_fixer' => null,
],
]);
$result = $method->invoke($this->jiraService, $workLogs, 'test-user');
$this->assertTrue($result['bugs']->has('生产环境BUG'));
$this->assertCount(1, $result['bugs']['生产环境BUG']);
$this->assertEquals('WP-7158', $result['bugs']['生产环境BUG'][0]['key']);
}
} }
+5 -1
View File
@@ -6,7 +6,11 @@ import vue from '@vitejs/plugin-vue';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/js/production-diagnosis.js'
],
refresh: true, refresh: true,
}), }),
tailwindcss(), tailwindcss(),