#feature: some update

This commit is contained in:
2026-08-12 18:00:09 +08:00
parent ba916f5ce1
commit 103340536b
39 changed files with 2916 additions and 227 deletions
+1 -1
View File
@@ -109,6 +109,7 @@ CRM_SERVICE_TIMEOUT=15
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
@@ -145,4 +146,3 @@ JENKINS_HOST=http://jenkins.example.com
JENKINS_USERNAME= JENKINS_USERNAME=
JENKINS_API_TOKEN= JENKINS_API_TOKEN=
JENKINS_TIMEOUT=30 JENKINS_TIMEOUT=30
+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
+252 -2
View File
@@ -92,10 +92,12 @@ class JenkinsClient
]; ];
} }
$path = $this->getJobPath($jobName).(empty($parameters) ? '/build' : '/buildWithParameters'); $path = $this->getJobPath($jobName).'/build?delay=0sec';
$url = $this->host.$path; $url = $this->host.$path;
try { try {
$jobInfo = $this->getJobInfo($jobName);
$nextBuildNumber = isset($jobInfo['nextBuildNumber']) ? (int) $jobInfo['nextBuildNumber'] : null;
$request = $this->http(); $request = $this->http();
$crumb = $this->getCrumb(); $crumb = $this->getCrumb();
if ($crumb) { if ($crumb) {
@@ -104,12 +106,20 @@ class JenkinsClient
$response = empty($parameters) $response = empty($parameters)
? $request->post($url) ? $request->post($url)
: $request->asForm()->post($url, $parameters); : $request->asForm()->post($url, [
'json' => json_encode([
'parameter' => $this->buildFormParameters($parameters),
'statusCode' => '303',
'redirectTo' => '.',
], JSON_UNESCAPED_UNICODE),
'Submit' => 'Build',
]);
if ($response->successful() || $response->status() === 201) { if ($response->successful() || $response->status() === 201) {
return [ return [
'success' => true, 'success' => true,
'queue_url' => $response->header('Location'), 'queue_url' => $response->header('Location'),
'build_number' => $nextBuildNumber,
'status' => $response->status(), 'status' => $response->status(),
]; ];
} }
@@ -158,6 +168,143 @@ class JenkinsClient
return $result; 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 private function request(string $path): ?array
{ {
if (! $this->isConfigured()) { if (! $this->isConfigured()) {
@@ -191,6 +338,60 @@ class JenkinsClient
} }
} }
private function post(string $path, string $operation): array
{
$url = $this->host.$path;
try {
$request = $this->http();
$crumb = $this->getCrumb();
if ($crumb) {
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
}
$response = $request->post($url);
if ($response->successful() || in_array($response->status(), [201, 302], true)) {
return [
'success' => true,
'status' => $response->status(),
];
}
Log::warning($operation.' failed', [
'url' => $url,
'status' => $response->status(),
'body' => $response->body(),
]);
return [
'success' => false,
'message' => 'Jenkins 返回状态码 '.$response->status(),
'status' => $response->status(),
];
} catch (\Throwable $e) {
Log::error($operation.' error', [
'url' => $url,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => $e->getMessage(),
];
}
}
private function buildFormParameters(array $parameters): array
{
return collect($parameters)
->map(fn ($value, $name) => [
'name' => $name,
'value' => $value,
])
->values()
->all();
}
private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string
{ {
if (! $this->isConfigured()) { if (! $this->isConfigured()) {
@@ -278,6 +479,55 @@ class JenkinsClient
return $parameters; 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 private function http(): PendingRequest
{ {
return Http::timeout($this->timeout) return Http::timeout($this->timeout)
@@ -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;
}
}
}
+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,
],
]);
}
}
@@ -82,6 +82,7 @@ class JenkinsBuildController extends Controller
'success' => (bool) ($result['success'] ?? false), 'success' => (bool) ($result['success'] ?? false),
'message' => $result['message'] ?? null, 'message' => $result['message'] ?? null,
'queue_url' => $result['queue_url'] ?? null, 'queue_url' => $result['queue_url'] ?? null,
'build_number' => $result['build_number'] ?? null,
]; ];
} }
@@ -94,6 +95,83 @@ class JenkinsBuildController extends Controller
], $successCount > 0 ? 200 : 422); ], $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 private function normalizeParameters(array $parameters): array
{ {
return collect($parameters) return collect($parameters)
@@ -5,13 +5,12 @@ namespace App\Http\Controllers;
use App\Services\ProductionDiagnosisService; use App\Services\ProductionDiagnosisService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class ProductionDiagnosisController extends Controller class ProductionDiagnosisController extends Controller
{ {
public function __construct(private readonly ProductionDiagnosisService $service) public function __construct(private readonly ProductionDiagnosisService $service) {}
{
}
/** /**
* 单条进产诊断 * 单条进产诊断
@@ -37,9 +36,15 @@ class ProductionDiagnosisController extends Controller
'errors' => $e->errors(), 'errors' => $e->errors(),
], 422); ], 422);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Production diagnosis failed.', [
'type' => $request->input('type'),
'code' => $request->input('code'),
'exception' => $e,
]);
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => '诊断失败: '.$e->getMessage(), 'message' => '诊断服务暂不可用,请稍后重试',
], 500); ], 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');
}
}
@@ -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'));
}
}
+2
View File
@@ -12,6 +12,7 @@ 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;
@@ -38,6 +39,7 @@ 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);
+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}";
}
}
+35 -3
View File
@@ -331,6 +331,16 @@ class JiraService
['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'], ['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'],
], ],
], ],
'mono' => [
'label' => 'mono',
'system' => 'SP',
'current_version' => $this->resolveTestMailCurrentVersion('portal-mono-be', '1.11.0.0'),
'database_enabled' => false,
'containers' => [
['name' => 'portal-mono-be-aplct', 'location' => '中国'],
['name' => 'portal-mono-be-web', 'location' => '中国'],
],
],
]; ];
foreach ($containerGroups as &$group) { foreach ($containerGroups as &$group) {
@@ -368,10 +378,15 @@ class JiraService
} }
private function nextTestMailVersion(string $version): string private function nextTestMailVersion(string $version): string
{
return $this->nextMinorVersion($version) ?? $version;
}
private function nextMinorVersion(string $version): ?string
{ {
$parts = explode('.', trim($version)); $parts = explode('.', trim($version));
if (count($parts) < 2 || ! ctype_digit($parts[1])) { if (count($parts) < 2 || ! ctype_digit($parts[1])) {
return $version; return null;
} }
$parts[1] = (string) ((int) $parts[1] + 1); $parts[1] = (string) ((int) $parts[1] + 1);
@@ -379,6 +394,20 @@ class JiraService
return implode('.', $parts); return implode('.', $parts);
} }
private function buildFallbackReleaseVersion(string $currentVersion): ?array
{
$version = $this->nextMinorVersion($currentVersion);
if ($version === null) {
return null;
}
return [
'version' => $version,
'description' => null,
'release_date' => null,
];
}
public function buildTestMailDatabases(array $selectedGroups, array $versions): array public function buildTestMailDatabases(array $selectedGroups, array $versions): array
{ {
$defaults = $this->getTestMailTemplateDefaults(); $defaults = $this->getTestMailTemplateDefaults();
@@ -389,6 +418,9 @@ class JiraService
continue; continue;
} }
$group = $defaults['container_groups'][$groupKey]; $group = $defaults['container_groups'][$groupKey];
if (($group['database_enabled'] ?? true) === false) {
continue;
}
$version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? '')); $version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? ''));
$branch = $version !== '' ? 'release/'.$version : ''; $branch = $version !== '' ? 'release/'.$version : '';
$exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch); $exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch);
@@ -1498,7 +1530,7 @@ class JiraService
->first(); ->first();
if (! $candidate) { if (! $candidate) {
return null; return $this->buildFallbackReleaseVersion($currentVersion);
} }
return [ return [
@@ -1517,7 +1549,7 @@ class JiraService
->first(); ->first();
if (! $candidate) { if (! $candidate) {
return null; return $this->buildFallbackReleaseVersion($currentVersion);
} }
return [ return [
+318 -19
View File
@@ -3,7 +3,9 @@
namespace App\Services; namespace App\Services;
use App\Clients\CrmClient; use App\Clients\CrmClient;
use App\Enums\CaseLabelBit;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/** /**
* 进产诊断服务 * 进产诊断服务
@@ -18,15 +20,31 @@ use Illuminate\Support\Facades\DB;
class ProductionDiagnosisService class ProductionDiagnosisService
{ {
public const TYPE_CASE = 'case'; public const TYPE_CASE = 'case';
public const TYPE_BUSINESS = 'business_document'; public const TYPE_BUSINESS = 'business_document';
public const TYPE_SALE = 'sale_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 数据库连接名 */ /** @var string agent-be 数据库连接名 */
private string $connection = 'agentslave'; private string $connection = 'agentslave';
public function __construct(private readonly CrmClient $crm) /** @var string CRM 数据库连接名,用于回溯 label_bit 原始值 */
{ private string $crmConnection = 'crmslave';
}
public function __construct(private readonly CrmClient $crm) {}
/** /**
* 执行单次诊断 * 执行单次诊断
@@ -49,11 +67,13 @@ class ProductionDiagnosisService
$operatorCode = (string) $entity->agent_code; $operatorCode = (string) $entity->agent_code;
$operatorAgent = $this->findAgent($operatorCode); $operatorAgent = $this->findAgent($operatorCode);
$checks = []; $checks = [];
$pfpContext = null;
$checks['status'] = $this->checkStatus($type, $entity); $checks['status'] = $this->checkStatus($type, $entity);
if ($type === self::TYPE_CASE) { if ($type === self::TYPE_CASE) {
$checks['need_pfp'] = $this->checkNeedPfp($entity); $pfpContext = $this->buildPfpContext($entity, $operatorCode);
$checks['need_pfp'] = $this->checkNeedPfp($pfpContext);
} }
$checks['owner_agent'] = $this->checkOwnerAgent($type, $entity, $operatorCode); $checks['owner_agent'] = $this->checkOwnerAgent($type, $entity, $operatorCode);
@@ -66,7 +86,7 @@ class ProductionDiagnosisService
'type_label' => $this->typeLabel($type), 'type_label' => $this->typeLabel($type),
'code' => $code, 'code' => $code,
'found' => true, 'found' => true,
'entity' => $this->normalizeEntity($type, $entity), 'entity' => $this->normalizeEntity($type, $entity, $pfpContext),
'operating_agent_code' => $operatorCode, 'operating_agent_code' => $operatorCode,
'operating_agent' => $operatorAgent ? [ 'operating_agent' => $operatorAgent ? [
'code' => (string) $operatorAgent->code, 'code' => (string) $operatorAgent->code,
@@ -144,29 +164,301 @@ class ProductionDiagnosisService
} }
/** /**
* 病例放行检查 - case.is_need_pfp > 0 * 进产原因检查 - case.is_need_pfp 位图
*
* is_need_pfp 来源于 CRM ea_case_cstm.label_bit,经 stuck_payment_reason
* 配置过滤后写入代理库;只有存在卡款原因的病例才会走代理端进产流程。
*
* @param array<string,mixed> $ctx buildPfpContext 的返回值
*/ */
private function checkNeedPfp(object $caseEntity): array private function checkNeedPfp(array $ctx): array
{ {
$isNeedPfp = (int) ($caseEntity->is_need_pfp ?? 0); $pass = $ctx['is_need_pfp'] > 0;
$isPfp = (int) ($caseEntity->is_pfp ?? 0); $reasonText = $ctx['reason_text'];
$pass = $isNeedPfp > 0;
$detail = $pass $actual = $pass
? '病例 is_need_pfp = '.$isNeedPfp.',命中放行节点' ? sprintf('进产原因:%sis_need_pfp = %d', $reasonText, $ctx['is_need_pfp'])
: '病例 is_need_pfp = 0,不需要放行(非欠款病例,不会触发进产流程)'; : sprintf('无进产原因(is_need_pfp = 0);放行状态:%s', $ctx['is_pfp_text']);
return [ return [
'key' => 'need_pfp', 'key' => 'need_pfp',
'label' => '放行节点检查 (is_need_pfp)', 'label' => '进产原因检查',
'pass' => $pass, 'pass' => $pass,
'expected' => 'is_need_pfp > 0', 'expected' => '病例存在卡生产原因('.$this->configOptionText($ctx).'',
'actual' => 'is_need_pfp = '.$isNeedPfp.', is_pfp = '.$isPfp, 'actual' => $actual,
'detail' => $detail, 'detail' => $this->pfpDetail($ctx, $pass),
'hint' => $pass ? null : '检查 stuck_payment_reason 配置以及病例账期推算逻辑', '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 * 1) entity.agent_code === operatorCode
@@ -379,6 +671,7 @@ class ProductionDiagnosisService
if (! $hasCredit) { if (! $hasCredit) {
$broken = true; $broken = true;
continue; continue;
} }
@@ -520,7 +813,10 @@ class ProductionDiagnosisService
return $type === self::TYPE_CASE ? (string) $entity->case_code : (string) $entity->code; return $type === self::TYPE_CASE ? (string) $entity->case_code : (string) $entity->code;
} }
private function normalizeEntity(string $type, object $entity): array /**
* @param array<string,mixed>|null $pfpContext
*/
private function normalizeEntity(string $type, object $entity, ?array $pfpContext = null): array
{ {
$base = [ $base = [
'status' => (int) $entity->status, 'status' => (int) $entity->status,
@@ -538,6 +834,9 @@ class ProductionDiagnosisService
'patient_name' => (string) ($entity->patient_name ?? ''), 'patient_name' => (string) ($entity->patient_name ?? ''),
'is_need_pfp' => (int) ($entity->is_need_pfp ?? 0), 'is_need_pfp' => (int) ($entity->is_need_pfp ?? 0),
'is_pfp' => (int) ($entity->is_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'] ?? '未知',
]; ];
} }
+7
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;
@@ -111,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';
} }
@@ -132,9 +135,11 @@ class ScheduledTaskService
'0 0 * * *' => '每天凌晨 0:00', '0 0 * * *' => '每天凌晨 0:00',
'0 2 * * *' => '每天凌晨 2:00', '0 2 * * *' => '每天凌晨 2:00',
'0 3 * * *' => '每天凌晨 3: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;
} }
@@ -149,9 +154,11 @@ class ScheduledTaskService
'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志', 'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志',
'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志', 'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志',
'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知', 'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知',
'erp-request-report' => 'ERP 请求日报 - 汇总前一天 OpenAPI 请求并发送钉钉',
'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表', 'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表',
'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志', 'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志',
]; ];
return $descriptions[$name] ?? $name; 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
View File
@@ -135,6 +135,14 @@ return [
'replace_placeholders' => true, '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' => [ 'git-monitor' => [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/git-monitor.log'), 'path' => storage_path('logs/scheduled-tasks/git-monitor.log'),
+1
View File
@@ -1,6 +1,7 @@
<?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', ''))
+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>
+525 -67
View File
@@ -6,12 +6,21 @@
<p class="text-xs text-gray-500">勾选 Jenkins 通知项目调整参数后批量触发 Build</p> <p class="text-xs text-gray-500">勾选 Jenkins 通知项目调整参数后批量触发 Build</p>
</div> </div>
<div class="flex items-center gap-2"> <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 <button
@click="loadProjects" @click="refreshProjects()"
:disabled="loading || triggering" :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" 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': loading}"> <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" /> <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> </svg>
刷新 刷新
@@ -35,6 +44,7 @@
<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="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 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-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="bg-gray-50 px-4 py-2 border-b border-gray-200 flex justify-between items-center">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -47,7 +57,7 @@
</label> </label>
</div> </div>
<div v-if="loading" class="p-10 text-center text-sm text-gray-400">加载 Jenkins 项目...</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"> <div v-else-if="projects.length === 0" class="p-10 text-center text-sm text-gray-400">
暂无启用 Jenkins 发布通知且配置 Job 名称的项目 暂无启用 Jenkins 发布通知且配置 Job 名称的项目
</div> </div>
@@ -80,7 +90,7 @@
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div class="grid grid-cols-[7rem_9rem_5.5rem_5rem_8rem_8rem] gap-2 items-start"> <div class="grid grid-cols-[7rem_9rem_5.5rem_8rem] gap-2 items-start">
<template v-for="parameterName in primaryParameterOrder" :key="parameterName"> <template v-for="parameterName in primaryParameterOrder" :key="parameterName">
<parameter-control <parameter-control
v-if="parameterByName(project, parameterName)" v-if="parameterByName(project, parameterName)"
@@ -128,42 +138,62 @@
</div> </div>
</div> </div>
<div v-if="results.length > 0" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> <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"> <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> <h4 class="font-semibold text-gray-700 text-sm">发布记录</h4>
<span class="text-xs text-gray-400">{{ operationRecords.length }}</span>
</div> </div>
<div class="overflow-x-auto"> <div v-if="operationRecords.length === 0" class="p-4 text-xs text-gray-400">
<table class="min-w-full divide-y divide-gray-200"> 暂无发布记录
<thead class="bg-gray-50"> </div>
<tr> <div v-else class="divide-y divide-gray-100 max-h-[calc(100vh-13rem)] overflow-y-auto">
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">项目</th> <div v-for="record in operationRecords" :key="record.id" class="px-3 py-2">
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job</th> <div class="flex items-center justify-between gap-2">
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th> <span class="font-mono text-[11px] text-gray-500">{{ record.time }}</span>
<th class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">队列</th> <div class="flex items-center gap-1.5">
</tr> <span class="rounded px-1.5 py-0.5 text-[11px]" :class="statusBadgeClass(record.status)">
</thead> {{ statusLabel(record.status) }}
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="result in results" :key="result.project_slug">
<td class="px-3 py-2 text-xs text-gray-800">{{ result.project_name }}</td>
<td class="px-3 py-2 text-xs font-mono text-gray-600">{{ result.job_name }}</td>
<td class="px-3 py-2 text-xs">
<span :class="result.success ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="px-2 py-0.5 rounded text-xs">
{{ result.success ? '已触发' : (result.message || '失败') }}
</span> </span>
</td> <button
<td class="px-3 py-2 text-xs"> @click="rebuildOperationRecord(record)"
<a v-if="result.queue_url" :href="result.queue_url" target="_blank" rel="noopener" class="text-blue-600 hover:text-blue-800 break-all">{{ result.queue_url }}</a> :disabled="record.rebuilding || !record.project_slug || triggering"
<span v-else class="text-gray-400">-</span> class="rounded border border-blue-200 px-1.5 py-0.5 text-[11px] text-blue-600 hover:bg-blue-50 disabled:opacity-50"
</td> >
</tr> {{ record.rebuilding ? 'rebuild中' : 'rebuild' }}
</tbody> </button>
</table> <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> </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> </div>
</template> </template>
<script> <script>
let cachedBuildProjects = null;
const ParameterControl = { const ParameterControl = {
name: 'ParameterControl', name: 'ParameterControl',
props: { props: {
@@ -268,13 +298,19 @@ export default {
ParameterControl ParameterControl
}, },
preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1', preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1',
cacheKey: 'toolbox.jenkinsBuilds.projectsCache.v1',
operationRecordsKey: 'toolbox.jenkinsBuilds.operationRecords.v1',
hiddenParameterNames: ['sql', 'masterCheck'],
data() { data() {
return { return {
loading: false, loading: false,
refreshing: false,
triggering: false, triggering: false,
statusChecking: false,
statusPollingTimer: null,
projects: [], projects: [],
results: [], operationRecords: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'sql', 'masterCheck', 'deployVersion'], primaryParameterOrder: ['env', 'branchName', 'deploy', 'deployVersion'],
message: '', message: '',
error: '' error: ''
}; };
@@ -285,17 +321,57 @@ export default {
}, },
isAllSelected() { isAllSelected() {
return this.projects.length > 0 && this.selectedProjects.length === this.projects.length; return this.projects.length > 0 && this.selectedProjects.length === this.projects.length;
},
runningOperationRecords() {
return this.operationRecords.filter((record) => this.isRunningStatus(record.status));
} }
}, },
async mounted() { async mounted() {
await this.loadProjects(); this.operationRecords = this.loadOperationRecords();
this.loadProjects();
this.ensureStatusPolling();
this.checkOperationStatuses();
},
beforeUnmount() {
this.stopStatusPolling();
}, },
methods: { methods: {
async loadProjects() { loadProjects() {
this.loading = true;
this.error = ''; this.error = '';
this.message = ''; 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 { try {
const response = await fetch('/api/admin/jenkins/build-projects', { const response = await fetch('/api/admin/jenkins/build-projects', {
headers: { Accept: 'application/json' } headers: { Accept: 'application/json' }
@@ -307,25 +383,71 @@ export default {
return; return;
} }
const preferences = this.loadPreferences(); cachedBuildProjects = data.data.projects || [];
this.projects = (data.data.projects || []).map((project) => { this.saveProjectsCache(cachedBuildProjects);
const parameters = project.parameters || []; this.applyProjects(cachedBuildProjects, { preserveCurrentValues: true });
const defaults = this.defaultValues(parameters);
const saved = preferences[this.preferenceProjectKey(project)] || {};
return { if (!silent) {
...project, this.message = 'Jenkins 项目已刷新';
selected: Boolean(saved.selected), }
parameters,
values: this.mergeSavedValues(defaults, saved.values || {}, parameters)
};
});
} catch (error) { } catch (error) {
this.error = error.message; this.error = error.message;
} finally { } finally {
this.loading = false; 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) { defaultValues(parameters) {
return parameters.reduce((values, parameter) => { return parameters.reduce((values, parameter) => {
if (parameter.multiple) { if (parameter.multiple) {
@@ -343,6 +465,9 @@ export default {
return values; return values;
}, {}); }, {});
}, },
visibleParameters(parameters) {
return parameters.filter((parameter) => !this.$options.hiddenParameterNames.includes(parameter.name));
},
mergeSavedValues(defaults, savedValues, parameters) { mergeSavedValues(defaults, savedValues, parameters) {
const merged = { ...defaults }; const merged = { ...defaults };
const parameterNames = new Set(parameters.map((parameter) => parameter.name)); const parameterNames = new Set(parameters.map((parameter) => parameter.name));
@@ -364,6 +489,17 @@ export default {
return {}; 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() { savePreferences() {
const preferences = this.projects.reduce((payload, project) => { const preferences = this.projects.reduce((payload, project) => {
payload[this.preferenceProjectKey(project)] = { payload[this.preferenceProjectKey(project)] = {
@@ -446,25 +582,15 @@ export default {
this.triggering = true; this.triggering = true;
this.error = ''; this.error = '';
this.message = ''; this.message = '';
this.results = []; 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 { try {
const response = await fetch('/api/admin/jenkins/trigger-builds', { const { response, data } = await this.submitBuilds(requestedBuilds);
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
builds: this.selectedProjects.map((project) => ({
project_slug: project.slug,
parameters: project.values
}))
})
});
const data = await response.json();
this.results = data.data?.results || [];
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
this.error = data.message || '触发失败'; this.error = data.message || '触发失败';
return; return;
@@ -476,6 +602,338 @@ export default {
} finally { } finally {
this.triggering = false; 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())}`;
} }
} }
} }
@@ -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() {
@@ -54,14 +54,15 @@
</div> </div>
<div class="max-h-[360px] overflow-auto rounded border"> <div class="max-h-[360px] overflow-auto rounded border">
<table class="dense-table min-w-[1180px]"> <table class="dense-table min-w-[1180px]">
<thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th></tr></thead> <thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th><th class="w-14">操作</th></tr></thead>
<tbody> <tbody>
<tr v-for="issue in issues" :key="issue.key"> <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><a :href="issue.url" target="_blank" class="text-blue-600">{{ issue.key }}</a></td>
<td class="min-w-72">{{ issue.summary }}</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>{{ 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>
<tr v-if="!issues.length"><td colspan="9" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr> <tr v-if="!issues.length"><td colspan="10" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -85,7 +86,7 @@
</div> </div>
<div class="mt-3 flex items-center justify-between gap-2"> <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> <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 版本号可各自调整</span> <span class="text-xs text-gray-500">agent / portal / portal-ticket / mono 版本号可各自调整</span>
</div> </div>
<div class="mt-3 overflow-auto rounded border"> <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> <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>
@@ -156,6 +157,7 @@ function normalizeTestMailSprintPeriod(value) {
} }
const LAST_SPRINT_STORAGE_KEY = 'toolbox.testMail.lastSprint'; const LAST_SPRINT_STORAGE_KEY = 'toolbox.testMail.lastSprint';
const RECIPIENTS_STORAGE_KEY = 'toolbox.testMail.recipients';
const EditableDenseTable = { const EditableDenseTable = {
props: ['headers', 'rows', 'columns'], emits: ['remove'], props: ['headers', 'rows', 'columns'], emits: ['remove'],
@@ -171,7 +173,7 @@ export default {
}, },
data() { return { data() { return {
steps: ['1 Sprint','2 收件人','3 Jira 表格','4 容器/数据库','5 八截图+九/十表格','6 下载'], steps: ['1 Sprint','2 收件人','3 Jira 表格','4 容器/数据库','5 八截图+九/十表格','6 下载'],
sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [], sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftRequestId: 0, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [],
from: '万文山 <wanwenshan@angelalign.com>', 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', 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', 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',
@@ -192,8 +194,12 @@ export default {
draftSourceLabel() { return this.draftSource === 'ai' ? 'AI' : '规则默认'; }, 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>`; } 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(); } }, watch: {
async mounted() { await this.loadSprints(); await this.loadData(); }, sprint(value) { this.rememberSprint(value); this.updateSubjectFromSprint(); },
to() { this.rememberRecipients(); },
cc() { this.rememberRecipients(); },
},
async mounted() { this.restoreRecipients(); await this.loadSprints(); await this.loadData(); },
methods: { methods: {
csrf() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); }, 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); } }, 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); } },
@@ -202,6 +208,9 @@ export default {
handleSprintSelection() { this.updateSubjectFromSprint(); this.loadData(); }, 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); } }, 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 ''; } }, 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); } }, 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); }, 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(); }, 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(); },
@@ -209,7 +218,7 @@ export default {
clearEnvironmentScreenshots() { this.images = this.images.filter(i => i.section !== 'environment'); this.$refs.environmentPasteBox.innerHTML = '<p class="text-gray-500">点击这里后直接粘贴「八、环境部署准备清单」截图</p>'; }, 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(); }, 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(); }, 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) { 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 (!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 { this.draftLoading = false; } }, 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}` : '')); }, updateSubjectFromSprint() { const sprint = this.sprint.trim(); this.subject = this.buildSubject(this.resolveSprintPeriod() || (sprint ? `Sprint${sprint}` : '')); },
buildSubject(period) { return `【提测】${period ? period : ''}需求提测(SP、PP、TP)`; }, 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 ''; }, 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 ''; },
@@ -58,6 +58,11 @@
<code class="font-mono">AgentBusinessDocument\\ConfirmProduction::canProduction</code> / <code class="font-mono">AgentBusinessDocument\\ConfirmProduction::canProduction</code> /
<code class="font-mono">AgentSaleDocument\\ConfirmPermit::canPermit</code> <code class="font-mono">AgentSaleDocument\\ConfirmPermit::canPermit</code>
</p> </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>
<!-- 错误 --> <!-- 错误 -->
@@ -146,6 +151,87 @@
提示{{ check.hint }} 提示{{ check.hint }}
</div> </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 v-if="check.key === 'credit' && check.chain && check.chain.length" class="mt-3">
<div class="text-xs text-gray-500 mb-1"> <div class="text-xs text-gray-500 mb-1">
@@ -251,8 +337,12 @@ export default {
{ key: 'hospital_code', label: '机构编号', value: e.hospital_code }, { key: 'hospital_code', label: '机构编号', value: e.hospital_code },
{ key: 'doctor_code', label: '医生编号', value: e.doctor_code }, { key: 'doctor_code', label: '医生编号', value: e.doctor_code },
{ key: 'patient_name', label: '患者姓名', value: e.patient_name }, { key: 'patient_name', label: '患者姓名', value: e.patient_name },
{ key: 'is_need_pfp', label: 'is_need_pfp', value: e.is_need_pfp }, {
{ key: 'is_pfp', label: 'is_pfp', value: e.is_pfp } 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 { } else {
base.push({ key: 'hospital_code', label: '机构编号', value: e.hospital_code }); base.push({ key: 'hospital_code', label: '机构编号', value: e.hospital_code });
+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>
+6 -1
View File
@@ -2,6 +2,7 @@
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\JenkinsBuildController;
use App\Http\Controllers\Admin\JenkinsDeploymentController; use App\Http\Controllers\Admin\JenkinsDeploymentController;
@@ -40,7 +41,7 @@ Route::prefix('sql-generator')->group(function () {
}); });
// 进产诊断 API 路由 // 进产诊断 API 路由
Route::prefix('production-diagnosis')->group(function () { Route::prefix('production-diagnosis')->middleware('throttle:30,1')->group(function () {
Route::post('/diagnose', [ProductionDiagnosisController::class, 'diagnose']); Route::post('/diagnose', [ProductionDiagnosisController::class, 'diagnose']);
}); });
@@ -90,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']);
@@ -118,6 +121,8 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
// Jenkins 发布历史 // Jenkins 发布历史
Route::get('/jenkins/build-projects', [JenkinsBuildController::class, 'projects']); Route::get('/jenkins/build-projects', [JenkinsBuildController::class, 'projects']);
Route::post('/jenkins/trigger-builds', [JenkinsBuildController::class, 'trigger']); 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', [JenkinsDeploymentController::class, 'index']);
Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']); Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']);
}); });
+9
View File
@@ -61,6 +61,15 @@ Schedule::command('jenkins:monitor')
->description('jenkins-monitor') ->description('jenkins-monitor')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('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()
->runInBackground()
->description('erp-request-report')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('erp-request-report'));
// 定时任务刷新 - 每天凌晨 3 点刷新定时任务列表 // 定时任务刷新 - 每天凌晨 3 点刷新定时任务列表
Schedule::command('scheduled-task:refresh') Schedule::command('scheduled-task:refresh')
->dailyAt('03:00') ->dailyAt('03:00')
+2 -1
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\AdminController; use App\Http\Controllers\AdminController;
use App\Http\Controllers\ProductionDiagnosisPageController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// 首页 - 显示admin框架 // 首页 - 显示admin框架
@@ -9,7 +10,7 @@ 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', [AdminController::class, 'index'])->name('admin.production-diagnosis'); 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('/test-mail', [AdminController::class, 'index'])->name('admin.test-mail');
+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();
}
}
+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();
}
}
+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));
}
}
+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';
});
}
}
+31
View File
@@ -5,6 +5,7 @@ namespace Tests\Unit;
use App\Services\JiraService; use App\Services\JiraService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use JiraRestApi\Project\ProjectService;
use Tests\TestCase; use Tests\TestCase;
class JiraServiceTest extends TestCase class JiraServiceTest extends TestCase
@@ -263,6 +264,25 @@ class JiraServiceTest extends TestCase
$this->assertEquals('2.70.0.0', $method->invoke($this->jiraService, '2.69.0.0')); $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() public function test_test_mail_template_defaults_use_next_versions()
{ {
$defaults = $this->jiraService->getTestMailTemplateDefaults(); $defaults = $this->jiraService->getTestMailTemplateDefaults();
@@ -270,6 +290,17 @@ class JiraServiceTest extends TestCase
$this->assertEquals('2.70.0.0', $defaults['container_groups']['agent']['default_version']); $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('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.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()
+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(),