238 lines
9.9 KiB
PHP
238 lines
9.9 KiB
PHP
<?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));
|
||
}
|
||
}
|