From 8c1b0b0840c4fee9c0edd2c0e8767a0589f4187b Mon Sep 17 00:00:00 2001 From: pllx Date: Sat, 9 May 2026 11:14:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=8A=82=E6=97=A5=E7=A6=8F?= =?UTF-8?q?=E5=88=A9=E8=BF=87=E6=9C=9F=E8=A1=A5=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Jobs/TriggerHolidayEventJob.php | 18 ++++++++-- app/Services/HolidayEventScheduleService.php | 33 +++++++++++++++++ tests/Feature/HolidayEventSchedulingTest.php | 37 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/app/Jobs/TriggerHolidayEventJob.php b/app/Jobs/TriggerHolidayEventJob.php index 1b82817..82a02a5 100644 --- a/app/Jobs/TriggerHolidayEventJob.php +++ b/app/Jobs/TriggerHolidayEventJob.php @@ -124,6 +124,9 @@ class TriggerHolidayEventJob implements ShouldQueue $now = now(); $scheduledFor = $this->manual ? $now->copy() : $event->send_at; + $expiresAt = $this->manual + ? $now->copy()->addMinutes($event->expire_minutes) + : $scheduledFor?->copy()->addMinutes($event->expire_minutes); if (! $this->manual) { // 定时触发只允许处理真正到期且仍处于 pending 的模板。 @@ -131,12 +134,23 @@ class TriggerHolidayEventJob implements ShouldQueue return null; } + $validScheduledFor = $scheduleService->skipExpiredOccurrences($event, $now); + if ($validScheduledFor === null || ! $validScheduledFor->equalTo($scheduledFor)) { + // 漏跑且已过期的批次只推进模板,不生成领取批次和聊天室公告。 + $event->update([ + 'send_at' => $validScheduledFor, + 'status' => $validScheduledFor ? 'pending' : 'completed', + ]); + + return null; + } + $nextSendAt = $scheduleService->advanceAfterTrigger($event); $event->update([ 'send_at' => $nextSendAt, 'status' => $nextSendAt ? 'pending' : 'completed', 'triggered_at' => $now, - 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), + 'expires_at' => $expiresAt, 'claimed_count' => 0, 'claimed_amount' => 0, ]); @@ -163,7 +177,7 @@ class TriggerHolidayEventJob implements ShouldQueue 'repeat_type' => $event->repeat_type, 'scheduled_for' => $scheduledFor, 'triggered_at' => $now, - 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), + 'expires_at' => $expiresAt, 'status' => 'active', 'audience_count' => 0, 'claimed_count' => 0, diff --git a/app/Services/HolidayEventScheduleService.php b/app/Services/HolidayEventScheduleService.php index 7e07408..fa41881 100644 --- a/app/Services/HolidayEventScheduleService.php +++ b/app/Services/HolidayEventScheduleService.php @@ -45,6 +45,39 @@ class HolidayEventScheduleService $currentSendAt = CarbonImmutable::instance($event->send_at); + return $this->nextOccurrenceAfter($event, $currentSendAt); + } + + /** + * 跳过已经超过领取窗口的历史计划点。 + */ + public function skipExpiredOccurrences(HolidayEvent $event, CarbonInterface $reference): ?CarbonImmutable + { + if ($event->send_at === null) { + return null; + } + + $candidate = CarbonImmutable::instance($event->send_at); + $referenceTime = CarbonImmutable::instance($reference); + $expireMinutes = max(0, (int) $event->expire_minutes); + + while ($candidate->addMinutes($expireMinutes)->lessThanOrEqualTo($referenceTime)) { + // 历史批次的领取窗口已经结束,只推进调度指针,不能补发金币。 + $candidate = $this->nextOccurrenceAfter($event, $candidate); + + if ($candidate === null) { + return null; + } + } + + return $candidate; + } + + /** + * 计算指定计划点之后的下一次触发时间。 + */ + private function nextOccurrenceAfter(HolidayEvent $event, CarbonImmutable $currentSendAt): ?CarbonImmutable + { return match ($event->repeat_type) { 'daily' => $currentSendAt->addDay(), 'weekly' => $currentSendAt->addWeek(), diff --git a/tests/Feature/HolidayEventSchedulingTest.php b/tests/Feature/HolidayEventSchedulingTest.php index 65c2c7f..fb06f47 100644 --- a/tests/Feature/HolidayEventSchedulingTest.php +++ b/tests/Feature/HolidayEventSchedulingTest.php @@ -145,6 +145,43 @@ class HolidayEventSchedulingTest extends TestCase $this->assertSame('2026-08-01 09:00:00', $event->fresh()?->send_at?->format('Y-m-d H:i:s')); } + /** + * 方法功能:验证本地停机导致年度福利过期后,自动任务只推进到下一次计划,不补发旧批次。 + */ + public function test_automatic_trigger_skips_expired_yearly_occurrences_after_downtime(): void + { + Event::fake([HolidayEventStarted::class, MessageSent::class]); + Queue::fake([SaveMessageJob::class]); + + $this->travelTo(CarbonImmutable::parse('2026-05-09 11:00:00')); + + $chatState = $this->createMock(ChatStateService::class); + $chatState->expects($this->never())->method('nextMessageId'); + $chatState->expects($this->never())->method('pushMessage'); + + $event = $this->createYearlyEvent([ + 'name' => '五一劳动节', + 'schedule_month' => 5, + 'schedule_day' => 1, + 'schedule_time' => '10:00', + 'duration_days' => 3, + 'daily_occurrences' => 4, + 'occurrence_interval_minutes' => 180, + 'send_at' => '2026-05-03 13:00:00', + 'expire_minutes' => 120, + ]); + + (new TriggerHolidayEventJob($event))->handle($chatState, app(HolidayEventScheduleService::class)); + + $freshEvent = $event->fresh(); + + $this->assertSame('2027-05-01 10:00:00', $freshEvent?->send_at?->format('Y-m-d H:i:s')); + $this->assertSame('pending', $freshEvent?->status); + $this->assertDatabaseCount('holiday_event_runs', 0); + Event::assertNotDispatched(HolidayEventStarted::class); + Event::assertNotDispatched(MessageSent::class); + } + /** * 方法功能:验证旧的 daily 重复模式仍按原逻辑继续推进。 */