- 工作台新增「钉钉机器人设置」:Webhook、加签密钥加密存公司设置,页面不回显;支持自定义关键词、汇总单独发群、@ 报销人花名 - 核对后点「发取消通知到钉钉群」:按发起人一人一条,按钉钉花名册(花名 → userid)@ 发起人;同一批已发过的不重复发,可勾选重发 - 新增 planting-reimbursement:daily-digest,每天 10:25(宿主机 cron)生成当天批次,把待核对条数/金额、可报销合计、取消/待复查数、自检结果和工作台链接推到群里;数据停更或看板未上传会提醒 - 种菜监控看板提示「职级为空」的单改为取消工单,群通知原因写「职级为空」(2026-11-13 报销人确认) - 没上传花名册时,收款人在职改按钉钉花名册在职真名判断;两边都取不到时不做该项校验并提示,避免误判 涉及:只读聚工单镜像与钉钉花名册副本;钉钉群机器人发消息;不涉及迁移,不写聚工单。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
239 lines
11 KiB
PHP
239 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services;
|
||
|
||
use Illuminate\Support\Facades\Crypt;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Illuminate\Validation\ValidationException;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 种菜报销钉钉群机器人:给「种菜对接问题处理群」按发起人发取消通知(@ 发起人),每天 10:30 推送批次汇总。
|
||
*
|
||
* Webhook、加签密钥加密存在公司设置里,不进代码和配置文件。已发过的通知按日期记在 storage,避免重复发。
|
||
*/
|
||
class PlantingReimbursementRobot
|
||
{
|
||
public const SETTING_KEY = 'planting_reimbursement.robot';
|
||
|
||
public function __construct(private readonly PlantingReimbursementDirectory $directory) {}
|
||
|
||
/** @return array{webhook:string,secret:string,keyword:string,reporters:list<string>,digest_webhook:string,digest_secret:string} */
|
||
public function config(): array
|
||
{
|
||
$value = setting(self::SETTING_KEY, []);
|
||
if (is_string($value)) {
|
||
$value = json_decode($value, true) ?: [];
|
||
}
|
||
$value = is_array($value) ? $value : [];
|
||
|
||
return [
|
||
'webhook' => $this->decrypt($value['webhook'] ?? ''),
|
||
'secret' => $this->decrypt($value['secret'] ?? ''),
|
||
'keyword' => trim((string) ($value['keyword'] ?? '')),
|
||
'reporters' => array_values(array_filter(array_map('trim', (array) ($value['reporters'] ?? [])))),
|
||
'digest_webhook' => $this->decrypt($value['digest_webhook'] ?? ''),
|
||
'digest_secret' => $this->decrypt($value['digest_secret'] ?? ''),
|
||
];
|
||
}
|
||
|
||
public function enabled(): bool
|
||
{
|
||
return $this->config()['webhook'] !== '';
|
||
}
|
||
|
||
/** 页面展示用:不回显 Webhook 和密钥。 */
|
||
public function summary(): array
|
||
{
|
||
$config = $this->config();
|
||
|
||
return [
|
||
'configured' => $config['webhook'] !== '',
|
||
'webhook' => $this->mask($config['webhook']),
|
||
'has_secret' => $config['secret'] !== '',
|
||
'keyword' => $config['keyword'],
|
||
'reporters' => implode(',', $config['reporters']),
|
||
'digest_webhook' => $this->mask($config['digest_webhook']),
|
||
'has_digest_secret' => $config['digest_secret'] !== '',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 保存设置。Webhook / 密钥留空表示沿用原值。
|
||
*
|
||
* @param array{webhook?:string,secret?:string,keyword?:string,reporters?:string,digest_webhook?:string,digest_secret?:string,clear_digest?:bool} $input
|
||
*/
|
||
public function save(array $input): void
|
||
{
|
||
$current = $this->config();
|
||
$webhook = trim((string) ($input['webhook'] ?? '')) ?: $current['webhook'];
|
||
$this->assertWebhook($webhook, 'webhook');
|
||
$secret = trim((string) ($input['secret'] ?? '')) ?: $current['secret'];
|
||
$digestWebhook = ! empty($input['clear_digest']) ? '' : (trim((string) ($input['digest_webhook'] ?? '')) ?: $current['digest_webhook']);
|
||
if ($digestWebhook !== '') {
|
||
$this->assertWebhook($digestWebhook, 'digest_webhook');
|
||
}
|
||
$digestSecret = ! empty($input['clear_digest']) ? '' : (trim((string) ($input['digest_secret'] ?? '')) ?: $current['digest_secret']);
|
||
$reporters = array_values(array_unique(array_filter(array_map('trim', preg_split('/[,,、\s]+/u', (string) ($input['reporters'] ?? '')) ?: []))));
|
||
|
||
foreach (['secret' => $secret, 'digest_secret' => $digestSecret] as $field => $value) {
|
||
if (mb_strlen($value) > 256) {
|
||
throw ValidationException::withMessages([$field => '加签密钥过长。']);
|
||
}
|
||
}
|
||
|
||
setting()->set(self::SETTING_KEY, json_encode([
|
||
'webhook' => Crypt::encryptString($webhook),
|
||
'secret' => $secret !== '' ? Crypt::encryptString($secret) : '',
|
||
'keyword' => mb_substr(trim((string) ($input['keyword'] ?? '')), 0, 50),
|
||
'reporters' => array_slice($reporters, 0, 20),
|
||
'digest_webhook' => $digestWebhook !== '' ? Crypt::encryptString($digestWebhook) : '',
|
||
'digest_secret' => $digestSecret !== '' ? Crypt::encryptString($digestSecret) : '',
|
||
], JSON_UNESCAPED_UNICODE));
|
||
setting()->save();
|
||
}
|
||
|
||
/**
|
||
* 按发起人发取消 / 待复查通知(一人一条,@ 发起人)。同一发起人、同一批单已发过的不再重发。
|
||
*
|
||
* @param list<array{initiator:string,items:list<array<string,string>>,message:string}> $notices
|
||
* @return array{sent:int,skipped:int,failed:int,unmatched:list<string>}
|
||
*/
|
||
public function sendNotices(int $companyId, string $date, array $notices, bool $force = false): array
|
||
{
|
||
$config = $this->config();
|
||
if ($config['webhook'] === '') {
|
||
throw new RuntimeException('还没配置钉钉群机器人。');
|
||
}
|
||
|
||
$log = $this->noticeLog($companyId, $date);
|
||
$userIds = $this->directory->userIds(array_column($notices, 'initiator'));
|
||
$result = ['sent' => 0, 'skipped' => 0, 'failed' => 0, 'unmatched' => []];
|
||
|
||
foreach ($notices as $notice) {
|
||
$initiator = $notice['initiator'];
|
||
$fingerprint = sha1(implode(',', array_map(static fn (array $item): string => $item['order_no'] . ':' . $item['decision'], $notice['items'])));
|
||
if (! $force && ($log[$initiator]['fingerprint'] ?? null) === $fingerprint) {
|
||
$result['skipped']++;
|
||
continue;
|
||
}
|
||
|
||
$userId = $userIds[$initiator] ?? null;
|
||
if ($userId === null) {
|
||
$result['unmatched'][] = $initiator;
|
||
}
|
||
$text = "#### 种菜报销 " . $date . " 不予报销通知\n\n"
|
||
. implode("\n", array_map(static fn (array $item): string => '- ' . $item['order_no'] . ':' . $item['reason'], $notice['items']))
|
||
. "\n\n" . ($userId !== null ? '@' . $userId : '@' . $initiator) . ' 以上订单本次不予报销,工单已取消(待复查的单稍后复核)。';
|
||
|
||
try {
|
||
$this->send($config['webhook'], $config['secret'], $config['keyword'], '种菜报销不予报销通知', $text, $userId !== null ? [$userId] : []);
|
||
$log[$initiator] = ['fingerprint' => $fingerprint, 'sent_at' => now()->format('Y-m-d H:i:s'), 'count' => count($notice['items'])];
|
||
$result['sent']++;
|
||
} catch (Throwable $exception) {
|
||
report($exception);
|
||
$result['failed']++;
|
||
}
|
||
}
|
||
|
||
$this->saveNoticeLog($companyId, $date, $log);
|
||
|
||
return $result;
|
||
}
|
||
|
||
/** @return array<string,array{fingerprint:string,sent_at:string,count:int}> 已发通知(按发起人) */
|
||
public function noticeLog(int $companyId, string $date): array
|
||
{
|
||
$path = $this->noticePath($companyId, $date);
|
||
$disk = Storage::disk('local');
|
||
$log = $disk->exists($path) ? json_decode((string) $disk->get($path), true) : [];
|
||
|
||
return is_array($log) ? $log : [];
|
||
}
|
||
|
||
/** 每日批次汇总:发到汇总群(未单独配置则发到通知群),@ 报销人。 */
|
||
public function sendDigest(string $title, string $text): void
|
||
{
|
||
$config = $this->config();
|
||
$webhook = $config['digest_webhook'] ?: $config['webhook'];
|
||
$secret = $config['digest_webhook'] !== '' ? $config['digest_secret'] : $config['secret'];
|
||
if ($webhook === '') {
|
||
throw new RuntimeException('还没配置钉钉群机器人。');
|
||
}
|
||
$userIds = array_values($this->directory->userIds($config['reporters']));
|
||
if ($userIds !== []) {
|
||
$text .= "\n\n" . implode(' ', array_map(static fn (string $id): string => '@' . $id, $userIds));
|
||
}
|
||
$this->send($webhook, $secret, $config['keyword'], $title, $text, $userIds);
|
||
}
|
||
|
||
/** @param list<string> $atUserIds */
|
||
private function send(string $webhook, string $secret, string $keyword, string $title, string $text, array $atUserIds): void
|
||
{
|
||
if ($keyword !== '' && ! str_contains($title . $text, $keyword)) {
|
||
$text .= "\n\n(" . $keyword . ')';
|
||
}
|
||
$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(10)->post($url, [
|
||
'msgtype' => 'markdown',
|
||
'markdown' => ['title' => $title, 'text' => $text],
|
||
'at' => ['atUserIds' => array_values($atUserIds), '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('钉钉机器人推送失败:' . ($body['errcode'] ?? '?') . ' ' . ($body['errmsg'] ?? ''));
|
||
}
|
||
}
|
||
|
||
private function saveNoticeLog(int $companyId, string $date, array $log): void
|
||
{
|
||
Storage::disk('local')->put($this->noticePath($companyId, $date), json_encode($log, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
private function noticePath(int $companyId, string $date): string
|
||
{
|
||
return PlantingReimbursementReferences::DIRECTORY . '/' . $companyId . '/notices/' . preg_replace('/[^0-9-]/', '', $date) . '.json';
|
||
}
|
||
|
||
private function assertWebhook(string $webhook, string $field): void
|
||
{
|
||
$parts = parse_url($webhook);
|
||
parse_str((string) ($parts['query'] ?? ''), $query);
|
||
if (($parts['scheme'] ?? '') !== 'https'
|
||
|| strtolower((string) ($parts['host'] ?? '')) !== 'oapi.dingtalk.com'
|
||
|| ($parts['path'] ?? '') !== '/robot/send'
|
||
|| trim((string) ($query['access_token'] ?? '')) === '') {
|
||
throw ValidationException::withMessages([$field => '请填写有效的钉钉群机器人 Webhook(https://oapi.dingtalk.com/robot/send?access_token= 开头)。']);
|
||
}
|
||
}
|
||
|
||
private function mask(string $webhook): string
|
||
{
|
||
return $webhook === '' ? '' : 'https://oapi.dingtalk.com/robot/send?access_token=••••••' . substr($webhook, -4);
|
||
}
|
||
|
||
private function decrypt(mixed $value): string
|
||
{
|
||
$value = trim((string) $value);
|
||
if ($value === '') {
|
||
return '';
|
||
}
|
||
try {
|
||
return Crypt::decryptString($value);
|
||
} catch (Throwable) {
|
||
return '';
|
||
}
|
||
}
|
||
}
|