Run the 15-min payment-expiry every minute through Symfony Scheduler so unpaid pending bookings flip to expired without a system crontab. Install symfony/scheduler; extract the expiry logic into AppointmentExpiryService (reused by the existing command); add ExpireAppointmentsMessage + handler and an #[AsSchedule] provider (RecurringMessage::every 1 minute); wire a scheduler_default transport in messenger.yaml. Slots already free just-in-time via isSlotTaken, so this only syncs the DB status. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.6 KiB
زمانبندی خودکار انقضای نوبتهای پرداختنشده با Symfony Scheduler
پروژه
clinicpro (Backend). تغییر صرفاً backend است.
زمینه
منطق قفل ۱۵دقیقهای نوبت از قبل کامل است:
Appointmentفیلدexpires_atدارد و موقع ساختmarkPendingWithTtl(900)میخورد.AppointmentRepository::isSlotTakenنوبت pendingِ منقضی را «گرفته» حساب نمیکند → اسلات همان لحظهی انقضا نرمآزاد میشود (کاربر بعدی میتواند رزرو کند، حتی قبل از اجرای هر job).AppointmentRepository::findPaymentExpired($now)نوبتهای pendingی کهexpires_at < nowرا برمیگرداند.App\Appointment\Command\CancelExpiredAppointmentsCommand(app:cancel-expired-appointments) اینها راpending → expiredمیکند تا وضعیت DB/داشبورد تمیز بماند.
شکاف: این command هیچ زمانبندیای ندارد و باید مرتب (هر ۱ دقیقه) اجرا شود. میخواهیم با Symfony Scheduler (داخل کد، ورژنخورده، مستقل از crontab سرور) این را هندل کنیم.
مشکل / هدف
یک Schedule در خود اپلیکیشن تعریف کن که هر ۱ دقیقه منطق انقضای نوبتهای پرداختنشده را اجرا کند، تا نوبتهای pendingی که ۱۵ دقیقهشان گذشته بهصورت خودکار expired شوند و وضعیت با واقعیتِ آزاد بودن اسلات همخوان بماند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
composer.json |
افزودن symfony/scheduler |
config/packages/messenger.yaml |
transport جدید scheduler_default و routing پیام schedule |
src/Appointment/Schedule/ExpireAppointmentsSchedule.php (جدید) |
ScheduleProvider با #[AsSchedule] |
src/Appointment/Message/ExpireAppointmentsMessage.php (جدید) |
پیام تریگر |
src/Appointment/MessageHandler/ExpireAppointmentsHandler.php (جدید) |
اجرای منطق انقضا |
src/Appointment/Command/CancelExpiredAppointmentsCommand.php |
منطق موجود — منطق را به یک سرویس مشترک منتقل کن تا هم command و هم handler از آن استفاده کنند |
src/Appointment/Repository/AppointmentRepository.php |
findPaymentExpired, findExpiredPending (موجود) |
clinicpro/CLAUDE.md یا README |
ذکر نحوهی اجرای worker |
وضعیت فعلی (کد واقعی)
messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
sync: 'sync://'
routing:
'App\Shared\Message\SendSmsMessage': async
Command (منطق انقضا که باید مشترک شود)
protected function execute(InputInterface $input, OutputInterface $output): int
{
$now = time();
$expired = [];
foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $a) {
$expired[$a->getUuid()] = $a;
}
$count = 0;
foreach ($expired as $a) {
$a->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($a, false);
$count++;
}
if ($count > 0) $this->appointmentRepo->save(reset($expired));
// ...
}
symfony/schedulerدر composer نصب نیست (فقطsymfony/messengerهست). باید نصب شود.
وظایف
۱. نصب symfony/scheduler
ddev composer require symfony/scheduler
- بررسی کن نسخه با
7.4.*بقیهی کامپوننتهای symfony همخوان باشد.
۲. استخراج منطق انقضا به یک سرویس مشترک
برای پرهیز از تکرار بین command و handler، یک سرویس بساز (مثلاً src/Appointment/Service/AppointmentExpiryService.php):
class AppointmentExpiryService
{
public function __construct(private readonly AppointmentRepository $appointmentRepo) {}
/** @return int تعداد نوبتهای منقضیشده */
public function expireStale(): int
{
$now = time();
$expired = [];
foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $a) {
$expired[$a->getUuid()] = $a;
}
$count = 0;
foreach ($expired as $a) {
$a->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($a, false);
$count++;
}
if ($count > 0) $this->appointmentRepo->save(reset($expired));
return $count;
}
}
CancelExpiredAppointmentsCommand::executeرا به فراخوانی$this->expiryService->expireStale()ساده کن (command برای اجرای دستی/دیباگ میماند).
۳. پیام و هندلر
src/Appointment/Message/ExpireAppointmentsMessage.php:
final class ExpireAppointmentsMessage {}
src/Appointment/MessageHandler/ExpireAppointmentsHandler.php:
#[AsMessageHandler]
final class ExpireAppointmentsHandler
{
public function __construct(private readonly AppointmentExpiryService $expiryService) {}
public function __invoke(ExpireAppointmentsMessage $message): void
{
$this->expiryService->expireStale();
}
}
۴. ScheduleProvider
src/Appointment/Schedule/ExpireAppointmentsSchedule.php:
#[AsSchedule('appointment_expiry')]
final class ExpireAppointmentsSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::every('1 minute', new ExpireAppointmentsMessage())
);
}
}
۵. transport و routing در messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
sync: 'sync://'
scheduler_default:
dsn: 'schedule://appointment_expiry'
routing:
'App\Shared\Message\SendSmsMessage': async
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
- نام schedule (
appointment_expiry) در#[AsSchedule(...)]وschedule://...باید یکی باشد.
۶. اجرای worker (مستندسازی + ddev)
- این مکانیزم نیاز به یک worker دائمی دارد:
php bin/console messenger:consume scheduler_default - در
clinicpro/CLAUDE.md(بخش Commands) این را اضافه کن. اگر ddev راهی برای daemonize دارد (مثلاًweb_extra_daemonsدر.ddev/config.yaml)، یک entry برای اجرای دائمی این consume اضافه کن تا در dev خودکار اجرا شود؛ اگر مطمئن نیستی، فقط مستند کن و متوقف شو و بپرس قبل از تغییر.ddev/config.yaml.
نکات مهم
- آزادسازی اسلات از قبل just-in-time است (در
isSlotTaken). این schedule صرفاً وضعیتpending → expiredرا همگام میکند؛ پس حتی اگر worker لحظهای down باشد، اسلاتها همچنان درست آزاد میمانند و فقط flip وضعیت تأخیر میگیرد. این را در پیام/گزارش ذکر کن. - idempotent:
expireStaleباید بارها قابلاجرا باشد بدون اثر جانبی (فقط pendingِ منقضی را flip میکند؛transitionToازALLOWED_TRANSITIONSعبور میکند). - تاریخها Unix timestamp؛ از
time()استفاده کن. - پیام/هندلر/سرویس را تمیز و کموابستگی نگهدار (فقط
AppointmentRepository). - تست:
ddev composer require symfony/schedulerموفقddev exec php -lروی فایلهای جدیدddev exec php bin/console cache:clearddev exec php bin/console debug:messengerیاdebug:scheduler→ scheduleappointment_expiryدیده شود- یک نوبت pending با
expires_atگذشته بساز (یا SQL آن را به گذشته ببر)، سپسddev exec php bin/console messenger:consume scheduler_default --limit=1 -vو تأیید کن نوبتexpiredشد و اسلات درisSlotTakenآزاد است. CancelExpiredAppointmentsCommandهنوز دستی کار کند (بعد از ریفکتور).