100 lines
5.9 KiB
PHP
100 lines
5.9 KiB
PHP
<?php
|
||
|
||
namespace App\Services\MailFlow;
|
||
|
||
use App\Models\MailDailyReport;
|
||
use App\Models\MailMessage;
|
||
use App\Models\MailQuotaBucket;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
class DailyReportService
|
||
{
|
||
public function __construct(private ResendClient $resend) {}
|
||
public function snapshot(Carbon $end): MailDailyReport
|
||
{
|
||
$start = $end->copy()->subDay();
|
||
$report = MailDailyReport::where('report_date', $start->toDateString())->first();
|
||
if ($report) { return $report; }
|
||
$rows = MailMessage::whereIn('direction', ['in', 'out'])
|
||
->whereRaw('COALESCE(submitted_at, first_attempt_at, created_at) >= ?', [$start->copy()->setTimezone(config('app.timezone'))->toDateTimeString()])
|
||
->whereRaw('COALESCE(submitted_at, first_attempt_at, created_at) < ?', [$end->copy()->setTimezone(config('app.timezone'))->toDateTimeString()])->get(['direction', 'purpose', 'channel', 'status', 'fallback_reason']);
|
||
$totals = [];
|
||
foreach ($rows as $row) {
|
||
$key = $row->direction . '/' . ($row->channel ?: ($row->status === 'policy_blocked' ? 'policy' : 'pending')) . '/' . $row->status;
|
||
$totals[$key] = ($totals[$key] ?? 0) + 1;
|
||
}
|
||
$usage = null;
|
||
if (Settings::get('resend_enabled') && Settings::get('resend_key')) {
|
||
try { $usage = $this->resend->usage(); } catch (\Throwable $e) { /* report local records with an explicit unavailable marker */ }
|
||
}
|
||
$summary = [
|
||
'resend_usage' => $usage,
|
||
'resend_usage_checked_at' => $usage ? now()->toIso8601String() : null,
|
||
'from' => $start->toIso8601String(), 'to' => $end->toIso8601String(),
|
||
'counts' => $totals, 'fallbacks' => $rows->whereNotNull('fallback_reason')->count(),
|
||
'backlog' => MailMessage::where('direction', 'out')->whereIn('status', ['pending', 'sending', 'unknown'])->count(),
|
||
'quota' => MailQuotaBucket::where('resets_at', '>', now())->get(['period', 'limit', 'used', 'reserved', 'synced_at'])->toArray(),
|
||
];
|
||
return MailDailyReport::firstOrCreate(['report_date' => $start->toDateString()], ['summary' => $summary]);
|
||
}
|
||
|
||
public function send(MailDailyReport $report): void
|
||
{
|
||
$lock = Cache::lock('mailflow:report:' . $report->id, 60);
|
||
if (!$lock->get()) { return; }
|
||
try {
|
||
$report->refresh();
|
||
if ($report->status === 'sending') {
|
||
$report->update(['status' => 'unknown', 'error_code' => 'master_worker_interrupted']);
|
||
}
|
||
if (!in_array($report->status, ['pending', 'failed'], true)) { return; }
|
||
$url = (string) Settings::get('master_url');
|
||
if (!str_starts_with($url, 'https://') || !filter_var($url, FILTER_VALIDATE_URL)
|
||
|| !Settings::get('master_bot_code') || !Settings::get('master_chat_code')) {
|
||
$report->update(['error_code' => 'master_configuration_required']);
|
||
return;
|
||
}
|
||
$report->increment('attempts');
|
||
$report->update(['status' => 'sending']);
|
||
$summary = $report->summary;
|
||
$text = '📧 ' . admin_setting('app_name', 'dpl') . ' 邮件日报(北京时间八点结算)';
|
||
foreach ($summary['counts'] as $key => $count) {
|
||
[$direction, $channel, $status] = explode('/', $key);
|
||
$labels = ['out' => '发送', 'in' => '收件', 'policy' => '邮件策略', 'resend' => 'Resend', 'conventional' => '常规邮箱', 'pending' => '待处理', 'submitted' => '已提交', 'delivered' => '已投递', 'delayed' => '投递延迟', 'bounced' => '退信', 'complained' => '投诉', 'suppressed' => '停止投递', 'failed' => '失败', 'unknown' => '结果未知', 'sending' => '提交中', 'received' => '已接收', 'simulated' => '模拟发送', 'expired' => '已过期', 'policy_blocked' => '因档位或套餐状态取消'];
|
||
$text .= "\n" . ($labels[$direction] ?? $direction) . ' / ' . ($labels[$channel] ?? $channel) . ' / ' . ($labels[$status] ?? $status) . ':' . $count;
|
||
}
|
||
$text .= "\n回退常规邮箱:" . $summary['fallbacks'] . "\n待处理/结果未知:" . $summary['backlog'];
|
||
if ($summary['resend_usage'] ?? null) {
|
||
$monthly = $summary['resend_usage']['monthly'] ?? [];
|
||
if (isset($monthly['used'], $monthly['limit'])) {
|
||
$text .= "\nResend 官方本月已用:" . $monthly['used'] . ' / ' . $monthly['limit'];
|
||
}
|
||
} else {
|
||
$text .= "\nResend 官方额度未能实时核对,以下为本地记录。";
|
||
}
|
||
foreach ($summary['quota'] as $quota) {
|
||
$text .= "\nResend " . $quota['period'] . ' 保守剩余额度:' . max(0, $quota['limit'] - $quota['used'] - $quota['reserved']);
|
||
}
|
||
try {
|
||
// Master currently exposes no idempotency guarantee. An uncertain response is not auto-retried.
|
||
$response = Http::connectTimeout(3)->timeout(8)->post(rtrim($url, '/') . '/api/telegram/post', [
|
||
'sBotCode' => Settings::get('master_bot_code'), 'sChatCode' => Settings::get('master_chat_code'),
|
||
'sMsg' => $text, 'enableMasterQueue' => false,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
$report->update(['status' => 'unknown', 'error_code' => 'master_transport_unknown']);
|
||
return;
|
||
}
|
||
if ($response->successful() && $response->json('isDone') === true) {
|
||
$report->update(['status' => 'submitted', 'submitted_at' => now(), 'error_code' => null]);
|
||
} else {
|
||
$report->update(['status' => 'unknown', 'error_code' => 'master_response_unknown']);
|
||
}
|
||
} finally {
|
||
$lock->release();
|
||
}
|
||
}
|
||
}
|