CaiWuXiTong/app/Business/StoreViolationNotifier.php

238 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Business;
use App\Models\Common\Company;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationPushRepository;
use RuntimeException;
use Throwable;
/**
* 店铺违规信息预警:内部群机器人推送(先留接口,配置 Webhook 后启用)。
*
* - P0/P1 即时推送(每天 09:00 起由定时任务检查),消息含去认领签名链接;
* - P2 不即时推,每日 10:00 汇总成一条日报;
* - 以违规记录为唯一对象:已推送的不再重复推送(失败可显式重试)。
*/
class StoreViolationNotifier
{
public function __construct(private readonly StoreViolationPushRepository $pushes)
{
}
public function enabled(): bool
{
$config = (array) config('store_violations.push', []);
return (bool) ($config['enabled'] ?? false)
&& trim((string) ($config['webhook'] ?? '')) !== '';
}
/**
* @return array{attempted:int,succeeded:int,failed:int}
*/
public function pushInstant(int $limit = 200, bool $retryFailed = false): array
{
$levels = array_values(array_map('strval', (array) config('store_violations.push.instant_levels', ['P0', 'P1'])));
$candidates = $this->pushes->instantCandidates($levels, $retryFailed, $limit);
$summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0];
$ownerContacts = (array) config('store_violations.push.owner_contacts', []);
foreach ($candidates as $record) {
$owner = trim((string) ($record->owner_name ?? ''));
$atMobiles = [];
if ($owner !== '' && ! empty($ownerContacts[$owner])) {
$atMobiles[] = (string) $ownerContacts[$owner];
}
[$title, $text] = $this->instantMessage($record);
$pushNo = $this->pushNo();
$now = Carbon::now();
try {
$this->send($title, $text, $atMobiles);
$this->pushes->markPushed([(int) $record->id], $now);
$summary['succeeded']++;
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'success',
'error' => null,
]);
} catch (Throwable $exception) {
$summary['failed']++;
$this->pushes->markPushFailed((int) $record->id, $now);
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
}
}
return $summary;
}
/**
* @return array{pending:array<string,int>,overdue:int,p2:int,sent:bool}
*/
public function pushDigest(): array
{
$data = $this->pushes->digestData();
$pending = $data['pending'];
$overdue = $data['overdue'];
$p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$lines = [
'### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')',
'- 待处理:P0 ' . (int) ($pending['P0'] ?? 0)
. ' 条 / P1 ' . (int) ($pending['P1'] ?? 0)
. ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条',
'- 已超时未认领:' . $overdue->count() . ' 条',
];
foreach ($overdue as $index => $record) {
$lines[] = sprintf(
' %d. [%s] %s(责任人:%s) · 截止 %s',
$index + 1,
(string) ($record->alert_level ?? ''),
(string) ($record->shop_name ?? ''),
trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配',
(string) ($record->handling_deadline ?? ''),
);
}
$now = Carbon::now();
try {
$this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []);
} catch (Throwable $exception) {
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false];
}
// 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。
$this->pushes->markPushed($p2Ids, $now);
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'success',
'error' => null,
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true];
}
/** @return array{0:string,1:string} */
private function instantMessage(object $record): array
{
$level = (string) ($record->alert_level ?? '');
$levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level);
$platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? ''));
$title = '店铺违规预警(' . $levelLabel . ')';
$lines = [
'### 【' . $levelLabel . '】店铺违规预警',
'- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''),
'- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200),
'- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'),
'- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'),
'- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'),
'- 处理截止:' . (string) ($record->handling_deadline ?? '—'),
];
$claimUrl = $this->claimUrl((int) $record->id);
if ($claimUrl !== null) {
$lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)';
}
return [$title, implode("\n", $lines)];
}
private function claimUrl(int $recordId): ?string
{
try {
$companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1);
return URL::signedRoute('business.store-violation-alert.claim', [
'record' => $recordId,
'company_id' => $companyId,
]);
} catch (Throwable) {
return null;
}
}
/** @param list<string> $atMobiles */
private function send(string $title, string $text, array $atMobiles): void
{
$config = (array) config('store_violations.push', []);
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw new RuntimeException('推送 Webhook 未配置。');
}
$url = $webhook;
$secret = trim((string) ($config['secret'] ?? ''));
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' => [
'atMobiles' => array_values($atMobiles),
'isAtAll' => (bool) ($config['at_all'] ?? 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 pushNo(): string
{
return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
}
}