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,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 $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)); } }