CaiWuXiTong/modules/EcommerceFinance/Services/SupplierNegativeAlertDingTalkWorkflow.php
苏木 79fad431df feat: 自动推送负数预警并支持处理截图 (#742)
每日自动推送未处理供应商,增加处理截止时间和截图上传。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-10-28 16:32:21 +08:00

799 lines
30 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
/**
* 供应商负数预警:向财务采购群机器人发送任务,并接收采购按供应商填写的处理结果。
*/
class SupplierNegativeAlertDingTalkWorkflow
{
public const SETTING_KEY = 'supplier_negative_alerts.dingtalk_group';
public const DEFAULT_GROUP_NAME = '财务&采购沟通群';
private const TOKEN_TTL_DAYS = 30;
/** @return array{configured:bool,webhook_hint:string,group_name:string} */
public function configuration(): array
{
$config = $this->config();
$webhook = trim((string) ($config['webhook'] ?? ''));
return [
'configured' => $webhook !== '',
'webhook_hint' => $this->maskWebhook($webhook),
'group_name' => trim((string) ($config['group_name'] ?? self::DEFAULT_GROUP_NAME)),
];
}
public function saveConfiguration(string $webhook, string $secret, string $groupName): void
{
$config = $this->config();
$existingWebhook = trim((string) ($config['webhook'] ?? ''));
$webhook = trim($webhook);
if ($webhook === '') {
$webhook = $existingWebhook;
}
if ($webhook === '') {
throw ValidationException::withMessages(['dingtalk_webhook' => '请填写钉钉群机器人 Webhook。']);
}
$this->assertWebhook($webhook);
$secret = trim($secret);
if ($secret === '' && $webhook === $existingWebhook) {
$secret = trim((string) ($config['secret'] ?? ''));
}
if (mb_strlen($secret) > 256) {
throw ValidationException::withMessages(['dingtalk_secret' => '机器人加签密钥过长。']);
}
$groupName = trim($groupName);
if ($groupName === '') {
$groupName = self::DEFAULT_GROUP_NAME;
}
if (mb_strlen($groupName) > 191) {
throw ValidationException::withMessages(['dingtalk_group_name' => '钉钉群名称不能超过 191 个字符。']);
}
setting()->set(self::SETTING_KEY, json_encode([
'webhook' => Crypt::encryptString($webhook),
'secret' => $secret !== '' ? Crypt::encryptString($secret) : '',
'group_name' => $groupName,
], JSON_UNESCAPED_UNICODE));
setting()->save();
}
/**
* @param list<array<string,mixed>> $rows 已按采购筛选的负数预警记录
* @param array<string,mixed> $filters
* @return array{assignment_id:int,supplier_count:int,row_count:int,group_name:string,task_url:string}
*/
public function send(
array $rows,
string $purchaser,
array $filters,
int $companyId,
?int $userId,
): array {
$purchaser = trim($purchaser);
$config = $this->config();
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw ValidationException::withMessages([
'dingtalk_send' => '财务&采购沟通群的机器人尚未配置。',
]);
}
$this->assertWebhook($webhook);
if ($purchaser === '') {
throw ValidationException::withMessages(['dingtalk_send' => '请先选择采购。']);
}
$supplierGroups = [];
foreach ($rows as $row) {
$supplier = trim((string) ($row['supplier'] ?? ''));
if ($supplier === '') {
continue;
}
if (trim((string) ($row['purchaser'] ?? '')) !== $purchaser) {
continue;
}
$supplierGroups[$supplier][] = [
'summary_month' => trim((string) ($row['summary_month'] ?? '')),
'checker' => trim((string) ($row['checker'] ?? '')),
'settlement_method' => trim((string) ($row['settlement_method'] ?? '')),
'negative_reason' => trim((string) ($row['negative_reason'] ?? '')),
'alert_amount' => round((float) ($row['_negative_alert_amount'] ?? 0), 2),
];
}
if ($supplierGroups === []) {
throw ValidationException::withMessages([
'dingtalk_send' => '当前筛选没有可发送给该采购的供应商。',
]);
}
$snapshot = [];
foreach ($supplierGroups as $supplier => $supplierRows) {
$months = array_values(array_unique(array_filter(array_column($supplierRows, 'summary_month'))));
rsort($months);
$reasons = array_values(array_unique(array_filter(array_column($supplierRows, 'negative_reason'))));
$snapshot[] = [
'supplier' => $supplier,
'row_count' => count($supplierRows),
'months' => $months,
'negative_reasons' => $reasons,
'alert_amount' => round(array_sum(array_column($supplierRows, 'alert_amount')), 2),
'rows' => $supplierRows,
];
}
$token = Str::random(64);
$groupName = trim((string) ($config['group_name'] ?? self::DEFAULT_GROUP_NAME));
$now = now();
$assignmentId = DB::table('supplier_negative_alert_assignments')->insertGetId([
'company_id' => $companyId,
'purchaser_name' => $purchaser,
'group_name' => $groupName,
'filters' => json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'supplier_count' => count($snapshot),
'row_count' => count($rows),
'token_hash' => hash('sha256', $token),
'token_expires_at' => $now->copy()->addDays(self::TOKEN_TTL_DAYS),
'status' => 'sending',
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
$taskUrl = route('supplier-negative-alert.task', ['token' => $token]);
$month = trim((string) ($filters['month'] ?? ''));
$title = '供应商负数预警 · ' . $purchaser;
$text = implode("\n", [
'### 供应商负数预警待处理',
'- 采购:**' . $purchaser . '**',
'- 月份:' . ($month !== '' ? $month : '全部月份'),
'- 供应商:**' . count($snapshot) . '** 家',
'- 预警记录:**' . count($rows) . '** 条',
'',
'请在钉钉内点击下方按钮,按每个供应商分别填写处理结果。',
]);
try {
$this->sendActionCard(
$webhook,
trim((string) ($config['secret'] ?? '')),
$title,
$text,
$taskUrl,
);
} catch (Throwable $exception) {
DB::table('supplier_negative_alert_assignments')->where('id', $assignmentId)->update([
'status' => 'failed',
'error_message' => mb_substr($exception->getMessage(), 0, 500),
'updated_at' => now(),
]);
throw $exception;
}
$sentAt = now();
DB::table('supplier_negative_alert_assignments')->where('id', $assignmentId)->update([
'status' => 'sent',
'sent_at' => $sentAt,
'error_message' => null,
'updated_at' => $sentAt,
]);
return [
'assignment_id' => $assignmentId,
'supplier_count' => count($snapshot),
'row_count' => count($rows),
'group_name' => $groupName,
'task_url' => $taskUrl,
];
}
/**
* 一次点击按采购分组,生成每人独立任务,并合成一条群消息发送。
*
* @param list<array<string,mixed>> $rows
* @param array<string,mixed> $filters
* @return array{
* purchaser_count:int,
* supplier_count:int,
* row_count:int,
* unassigned_supplier_count:int,
* group_name:string,
* task_urls:array<string,string>
* }
*/
public function sendGrouped(
array $rows,
array $filters,
int $companyId,
?int $userId,
): array {
$config = $this->config();
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw ValidationException::withMessages([
'dingtalk_send' => '财务&采购沟通群的机器人尚未配置。',
]);
}
$this->assertWebhook($webhook);
$purchaserGroups = [];
$unassignedSuppliers = [];
$unassignedRows = 0;
foreach ($rows as $row) {
$supplier = trim((string) ($row['supplier'] ?? ''));
if ($supplier === '') {
continue;
}
$purchaser = trim((string) ($row['purchaser'] ?? ''));
if ($purchaser === '') {
$unassignedSuppliers[$supplier] = true;
$unassignedRows++;
continue;
}
$purchaserGroups[$purchaser][] = $row;
}
if ($purchaserGroups === []) {
throw ValidationException::withMessages([
'dingtalk_send' => '当前筛选没有可发送给采购的供应商。',
]);
}
$groupName = trim((string) ($config['group_name'] ?? self::DEFAULT_GROUP_NAME));
$assignments = [];
$supplierCount = 0;
$rowCount = 0;
foreach ($purchaserGroups as $purchaser => $purchaserRows) {
$snapshot = $this->snapshotForRows($purchaserRows);
if ($snapshot === []) {
continue;
}
$token = Str::random(64);
$now = now();
$assignmentId = DB::table('supplier_negative_alert_assignments')->insertGetId([
'company_id' => $companyId,
'purchaser_name' => $purchaser,
'group_name' => $groupName,
'filters' => json_encode($filters, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'supplier_count' => count($snapshot),
'row_count' => count($purchaserRows),
'token_hash' => hash('sha256', $token),
'token_expires_at' => $now->copy()->addDays(self::TOKEN_TTL_DAYS),
'status' => 'sending',
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
$assignments[] = [
'id' => $assignmentId,
'purchaser' => $purchaser,
'supplier_count' => count($snapshot),
'row_count' => count($purchaserRows),
'task_url' => route('supplier-negative-alert.task', ['token' => $token]),
];
$supplierCount += count($snapshot);
$rowCount += count($purchaserRows);
}
if ($assignments === []) {
throw ValidationException::withMessages([
'dingtalk_send' => '当前筛选没有可发送给采购的供应商。',
]);
}
$month = trim((string) ($filters['month'] ?? ''));
$lines = [
'### 供应商负数预警待处理',
'- 月份:' . ($month !== '' ? $month : '全部月份'),
'- 采购:**' . count($assignments) . '** 位',
'- 供应商:**' . $supplierCount . '** 家',
'- 预警记录:**' . $rowCount . '** 条',
];
if ($unassignedSuppliers !== []) {
$lines[] = '- 未分配采购:**' . count($unassignedSuppliers)
. '** 家供应商(' . $unassignedRows . ' 条预警,未发送)';
}
$lines[] = '';
$lines[] = '请对应采购点击自己的链接,按每家供应商分别填写处理结果:';
foreach ($assignments as $assignment) {
$lines[] = '- [' . $assignment['purchaser'] . ' · '
. $assignment['supplier_count'] . ' 家供应商](' . $assignment['task_url'] . ')';
}
$assignmentIds = array_column($assignments, 'id');
try {
$this->sendMarkdown(
$webhook,
trim((string) ($config['secret'] ?? '')),
'供应商负数预警 · 按采购分派',
implode("\n", $lines),
);
} catch (Throwable $exception) {
DB::table('supplier_negative_alert_assignments')->whereIn('id', $assignmentIds)->update([
'status' => 'failed',
'error_message' => mb_substr($exception->getMessage(), 0, 500),
'updated_at' => now(),
]);
throw $exception;
}
$sentAt = now();
DB::table('supplier_negative_alert_assignments')->whereIn('id', $assignmentIds)->update([
'status' => 'sent',
'sent_at' => $sentAt,
'error_message' => null,
'updated_at' => $sentAt,
]);
return [
'purchaser_count' => count($assignments),
'supplier_count' => $supplierCount,
'row_count' => $rowCount,
'unassigned_supplier_count' => count($unassignedSuppliers),
'group_name' => $groupName,
'task_urls' => array_column($assignments, 'task_url', 'purchaser'),
];
}
public function findByToken(string $token): ?object
{
if ($token === '') {
return null;
}
return DB::table('supplier_negative_alert_assignments')
->where('token_hash', hash('sha256', $token))
->where('token_expires_at', '>=', now())
->whereIn('status', ['sent', 'processing', 'completed'])
->first();
}
/** @param list<string> $results */
public function submit(string $token, array $results): object
{
$assignment = $this->findByToken($token);
if ($assignment === null) {
throw ValidationException::withMessages(['results' => '任务链接无效或已过期,请财务重新发送。']);
}
$snapshot = json_decode((string) $assignment->snapshot, true);
$snapshot = is_array($snapshot) ? array_values($snapshot) : [];
if ($snapshot === [] || count($results) !== count($snapshot)) {
throw ValidationException::withMessages(['results' => '请为每个供应商填写处理结果。']);
}
$period = $this->latestPeriodSummary($snapshot)['month'];
$submittedCount = 0;
$now = now();
DB::transaction(function () use ($assignment, $snapshot, $results, $period, $now, &$submittedCount): void {
foreach ($snapshot as $index => $supplier) {
$supplierName = trim((string) ($supplier['supplier'] ?? ''));
$resultText = trim((string) ($results[$index] ?? ''));
if ($supplierName === '' || $resultText === '') {
continue;
}
DB::table('supplier_negative_alert_results')->updateOrInsert(
[
'assignment_id' => (int) $assignment->id,
'supplier_name' => $supplierName,
],
[
'company_id' => (int) $assignment->company_id,
'purchaser_name' => (string) $assignment->purchaser_name,
'period' => $period !== '' ? $period : null,
'result_text' => $resultText,
'submitted_at' => $now,
'created_at' => $now,
'updated_at' => $now,
],
);
$submittedCount++;
}
if ($submittedCount === 0) {
throw ValidationException::withMessages([
'results' => '请至少填写一个供应商的处理结果。',
]);
}
$handledCount = DB::table('supplier_negative_alert_results')
->where('assignment_id', (int) $assignment->id)
->where('result_text', '!=', '')
->count();
$completed = $handledCount >= count($snapshot);
DB::table('supplier_negative_alert_assignments')->where('id', $assignment->id)->update([
'status' => $completed ? 'completed' : 'processing',
'completed_at' => $completed ? $now : null,
'updated_at' => $now,
]);
});
return DB::table('supplier_negative_alert_assignments')->where('id', $assignment->id)->first();
}
/**
* @param list<string> $suppliers
* @return array<string,object>
*/
public function latestResults(array $suppliers, int $companyId): array
{
$suppliers = array_values(array_unique(array_filter(array_map('trim', $suppliers))));
if ($suppliers === []) {
return [];
}
$rows = DB::table('supplier_negative_alert_results')
->where('company_id', $companyId)
->whereIn('supplier_name', $suppliers)
->orderByDesc('submitted_at')
->orderByDesc('id')
->get(['supplier_name', 'purchaser_name', 'result_text', 'submitted_at']);
$latest = [];
foreach ($rows as $row) {
$supplier = trim((string) $row->supplier_name);
if ($supplier !== '' && ! isset($latest[$supplier])) {
$latest[$supplier] = $row;
}
}
return $latest;
}
/**
* 任务页展示:按任务快照中的最新账期汇总该采购的负数金额。
*
* @param list<array<string,mixed>> $snapshot
* @return array{month:string,negative_amount:float,supplier_count:int,row_count:int}
*/
public function latestPeriodSummary(array $snapshot): array
{
$latestMonth = '';
foreach ($snapshot as $supplier) {
foreach ((array) ($supplier['rows'] ?? []) as $row) {
$month = trim((string) ($row['summary_month'] ?? ''));
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1 && $month > $latestMonth) {
$latestMonth = $month;
}
}
}
$negativeAmount = 0.0;
$supplierNames = [];
$rowCount = 0;
foreach ($snapshot as $supplier) {
$supplierName = trim((string) ($supplier['supplier'] ?? ''));
foreach ((array) ($supplier['rows'] ?? []) as $row) {
$month = trim((string) ($row['summary_month'] ?? ''));
if ($latestMonth !== '' && $month !== $latestMonth) {
continue;
}
$amount = (float) ($row['alert_amount'] ?? 0);
if ($amount >= 0) {
continue;
}
$negativeAmount += $amount;
$rowCount++;
if ($supplierName !== '') {
$supplierNames[$supplierName] = true;
}
}
}
return [
'month' => $latestMonth,
'negative_amount' => round($negativeAmount, 2),
'supplier_count' => count($supplierNames),
'row_count' => $rowCount,
];
}
public function processingDeadline(string $period): ?string
{
if (preg_match('/^\d{4}-\d{2}$/', $period) !== 1) {
return null;
}
return CarbonImmutable::createFromFormat(
'!Y-m-d',
$period . '-01',
(string) config('app.timezone', 'Asia/Shanghai'),
)->addMonthNoOverflow()->endOfMonth()->setTime(18, 0)->format('Y-m-d H:i');
}
/** @return list<string> */
public function handledSuppliersForPeriod(int $companyId, string $period): array
{
if (preg_match('/^\d{4}-\d{2}$/', $period) !== 1) {
return [];
}
return DB::table('supplier_negative_alert_results')
->where('company_id', $companyId)
->where('period', $period)
->where('result_text', '!=', '')
->pluck('supplier_name')
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->unique()
->values()
->all();
}
/** @return array<string,list<object>> */
public function attachmentsForAssignment(int $assignmentId): array
{
$attachments = [];
foreach (DB::table('supplier_negative_alert_result_attachments')
->where('assignment_id', $assignmentId)
->orderBy('id')
->get() as $attachment) {
$attachments[trim((string) $attachment->supplier_name)][] = $attachment;
}
return $attachments;
}
/**
* @param list<array<string,mixed>> $snapshot
* @param array<int,array<int,UploadedFile>> $uploads
*/
public function storeAttachments(object $assignment, array $snapshot, array $uploads): void
{
if ($uploads === []) {
return;
}
$existingCounts = DB::table('supplier_negative_alert_result_attachments')
->where('assignment_id', (int) $assignment->id)
->selectRaw('supplier_name, COUNT(*) AS aggregate')
->groupBy('supplier_name')
->pluck('aggregate', 'supplier_name')
->map(static fn (mixed $count): int => (int) $count)
->all();
$disk = Storage::disk('uploads');
foreach ($snapshot as $index => $supplier) {
$supplierName = trim((string) ($supplier['supplier'] ?? ''));
$files = array_values(array_filter(
(array) ($uploads[$index] ?? []),
static fn (mixed $file): bool => $file instanceof UploadedFile,
));
if ($supplierName === '' || $files === []) {
continue;
}
$count = (int) ($existingCounts[$supplierName] ?? 0);
foreach ($files as $file) {
if (++$count > 5) {
throw ValidationException::withMessages([
'screenshots' => $supplierName . ' 最多上传 5 张处理截图。',
]);
}
$extension = strtolower($file->getClientOriginalExtension());
$filename = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : '');
$path = $disk->putFileAs(
'supplier-negative-alerts/' . (int) $assignment->company_id . '/'
. (int) $assignment->id . '/' . hash('sha256', $supplierName),
$file,
$filename,
);
if ($path === false) {
throw new RuntimeException('处理截图保存失败。');
}
DB::table('supplier_negative_alert_result_attachments')->insert([
'assignment_id' => (int) $assignment->id,
'company_id' => (int) $assignment->company_id,
'supplier_name' => $supplierName,
'path' => $path,
'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 255),
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size' => $file->getSize() ?: 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
public function attachmentForAssignment(int $assignmentId, int $attachmentId): ?object
{
return DB::table('supplier_negative_alert_result_attachments')
->where('assignment_id', $assignmentId)
->where('id', $attachmentId)
->first();
}
/** @return array<string,string> */
public function resultsForAssignment(int $assignmentId): array
{
return DB::table('supplier_negative_alert_results')
->where('assignment_id', $assignmentId)
->orderBy('id')
->pluck('result_text', 'supplier_name')
->map(static fn (mixed $value): string => trim((string) $value))
->all();
}
/** @return array<string,mixed> */
private function config(): array
{
$value = setting(self::SETTING_KEY, []);
if (is_string($value)) {
$value = json_decode($value, true) ?: [];
}
if (! is_array($value)) {
return [];
}
$value['webhook'] = $this->decryptValue($value['webhook'] ?? '');
$value['secret'] = $this->decryptValue($value['secret'] ?? '');
return $value;
}
private function sendActionCard(
string $webhook,
string $secret,
string $title,
string $text,
string $taskUrl,
): void {
$url = $webhook;
if ($secret !== '') {
$timestamp = (string) (int) (microtime(true) * 1000);
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
}
$response = Http::timeout(15)->post($url, [
'msgtype' => 'actionCard',
'actionCard' => [
'title' => $title,
'text' => $text,
'btnOrientation' => '0',
'singleTitle' => '填写处理结果',
'singleURL' => $taskUrl,
],
'at' => ['atMobiles' => [], 'isAtAll' => false],
]);
if (! $response->successful()) {
throw new RuntimeException('钉钉群机器人返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException(
'钉钉群机器人发送失败:' . (string) ($body['errcode'] ?? '?')
. ' ' . (string) ($body['errmsg'] ?? ''),
);
}
}
private function sendMarkdown(
string $webhook,
string $secret,
string $title,
string $text,
): void {
$url = $webhook;
if ($secret !== '') {
$timestamp = (string) (int) (microtime(true) * 1000);
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
}
$response = Http::timeout(15)->post($url, [
'msgtype' => 'markdown',
'markdown' => ['title' => $title, 'text' => $text],
'at' => ['atMobiles' => [], 'isAtAll' => false],
]);
if (! $response->successful()) {
throw new RuntimeException('钉钉群机器人返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException(
'钉钉群机器人发送失败:' . (string) ($body['errcode'] ?? '?')
. ' ' . (string) ($body['errmsg'] ?? ''),
);
}
}
/**
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
private function snapshotForRows(array $rows): array
{
$supplierGroups = [];
foreach ($rows as $row) {
$supplier = trim((string) ($row['supplier'] ?? ''));
if ($supplier === '') {
continue;
}
$supplierGroups[$supplier][] = [
'summary_month' => trim((string) ($row['summary_month'] ?? '')),
'checker' => trim((string) ($row['checker'] ?? '')),
'settlement_method' => trim((string) ($row['settlement_method'] ?? '')),
'negative_reason' => trim((string) ($row['negative_reason'] ?? '')),
'alert_amount' => round((float) ($row['_negative_alert_amount'] ?? 0), 2),
];
}
$snapshot = [];
foreach ($supplierGroups as $supplier => $supplierRows) {
$months = array_values(array_unique(array_filter(array_column($supplierRows, 'summary_month'))));
rsort($months);
$reasons = array_values(array_unique(array_filter(array_column($supplierRows, 'negative_reason'))));
$snapshot[] = [
'supplier' => $supplier,
'row_count' => count($supplierRows),
'months' => $months,
'negative_reasons' => $reasons,
'alert_amount' => round(array_sum(array_column($supplierRows, 'alert_amount')), 2),
'rows' => $supplierRows,
];
}
return $snapshot;
}
private function assertWebhook(string $webhook): void
{
$parts = parse_url($webhook);
parse_str((string) ($parts['query'] ?? ''), $query);
$host = strtolower((string) ($parts['host'] ?? ''));
if (($parts['scheme'] ?? '') !== 'https'
|| ! in_array($host, ['oapi.dingtalk.com', 'api.dingtalk.com'], true)
|| ($parts['path'] ?? '') !== '/robot/send'
|| trim((string) ($query['access_token'] ?? '')) === '') {
throw ValidationException::withMessages([
'dingtalk_webhook' => '请填写有效的钉钉群机器人 Webhook。',
]);
}
}
private function maskWebhook(string $webhook): string
{
if ($webhook === '') {
return '';
}
$host = (string) (parse_url($webhook, PHP_URL_HOST) ?? '');
return $host !== '' ? 'https://' . $host . '/robot/send?access_token=••••••' : '已配置';
}
private function decryptValue(mixed $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
try {
return Crypt::decryptString($value);
} catch (Throwable) {
return $value;
}
}
}