Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
119 lines
3.9 KiB
PHP
119 lines
3.9 KiB
PHP
<?php
|
||
|
||
namespace App\Delivery;
|
||
|
||
use App\Channels\ChannelRegistry;
|
||
use App\Enums\DeliveryResult;
|
||
use App\Enums\EventState;
|
||
use App\Enums\Presence;
|
||
use App\Models\Channel;
|
||
use App\Models\Delivery;
|
||
use App\Models\Event;
|
||
use App\Models\User;
|
||
use App\Scheduling\DueResolver;
|
||
use App\Scheduling\StateResolver;
|
||
use App\Scheduling\Window;
|
||
use App\Scheduling\WindowResolver;
|
||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Str;
|
||
use Throwable;
|
||
|
||
/**
|
||
* Один тик планировщика (раз в минуту): переходы состояний по датам и решение,
|
||
* доставлять ли напоминание в открытое окно и куда.
|
||
*/
|
||
final class Tick
|
||
{
|
||
public function __construct(
|
||
private readonly DueResolver $due,
|
||
private readonly StateResolver $states,
|
||
private readonly WindowResolver $windows,
|
||
private readonly ChannelRegistry $registry,
|
||
private readonly Cache $cache,
|
||
private readonly int $presenceTtl,
|
||
) {}
|
||
|
||
public function run(): void
|
||
{
|
||
Event::query()
|
||
->whereIn('state', EventState::nonTerminalValues())
|
||
->with('user')
|
||
->chunkById(200, function (Collection $events) {
|
||
foreach ($events as $event) {
|
||
try {
|
||
$this->process($event);
|
||
} catch (Throwable $e) {
|
||
Log::error('hado.tick: событие пропущено', ['event_id' => $event->id, 'error' => $e->getMessage()]);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
private function process(Event $event): void
|
||
{
|
||
$user = $event->user;
|
||
$now = $user->now();
|
||
|
||
$state = $this->states->resolve($event, $user, $now);
|
||
if ($state !== $event->state) {
|
||
$event->state = $state;
|
||
$event->save();
|
||
}
|
||
if (! $state->isActive() || $now->lt($event->quiet_until)) {
|
||
return;
|
||
}
|
||
|
||
$window = $this->windows->current($state, $user, $this->due->resolve($event, $user), $now);
|
||
if ($window === null) {
|
||
return;
|
||
}
|
||
if (Delivery::where('event_id', $event->id)->where('window_start', $window->start->utc())->exists()) {
|
||
return;
|
||
}
|
||
|
||
$channels = $user->channels()->where('enabled', true)->get();
|
||
if ($channels->isEmpty()) {
|
||
return;
|
||
}
|
||
|
||
$present = $channels->filter(fn (Channel $c) => $this->presence($c, $user) === Presence::Present);
|
||
if ($present->isNotEmpty()) {
|
||
$this->dispatch($event, $present, $window);
|
||
|
||
return;
|
||
}
|
||
if ($window->fallbackAllowed && $now->gte($window->fallbackAt)) {
|
||
$this->dispatch($event, $channels, $window);
|
||
}
|
||
}
|
||
|
||
private function presence(Channel $channel, User $user): Presence
|
||
{
|
||
return $this->cache->remember(
|
||
"presence:{$channel->id}",
|
||
$this->presenceTtl,
|
||
fn () => $this->registry->for($channel->type)->presence($user, $channel->config),
|
||
);
|
||
}
|
||
|
||
/** Строка pending — в транзакции ДО постановки job-а: следующий тик её уже видит. */
|
||
private function dispatch(Event $event, Collection $channels, Window $window): void
|
||
{
|
||
DB::transaction(function () use ($event, $channels, $window) {
|
||
foreach ($channels as $channel) {
|
||
$delivery = Delivery::create([
|
||
'event_id' => $event->id,
|
||
'channel_id' => $channel->id,
|
||
'window_start' => $window->start->utc(),
|
||
'action_token' => Str::random(48),
|
||
'result' => DeliveryResult::Pending,
|
||
]);
|
||
DeliverJob::dispatch($delivery->id)->afterCommit();
|
||
}
|
||
});
|
||
}
|
||
}
|