diff --git a/app/Http/Controllers/Admin/PlatformOrderController.php b/app/Http/Controllers/Admin/PlatformOrderController.php index 2a22d9f..305b84b 100644 --- a/app/Http/Controllers/Admin/PlatformOrderController.php +++ b/app/Http/Controllers/Admin/PlatformOrderController.php @@ -11,6 +11,7 @@ use App\Models\SiteSubscription; use App\Support\BackUrl; use App\Support\SubscriptionActivationService; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Cache; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -1717,6 +1718,14 @@ class PlatformOrderController extends Controller // 队列化(M3 可运维化第二步):把批量 BMPA 投递为一个 Job,避免请求超时,并对齐 BAS 的批次可观测模式。 $orderIds = $orders->pluck('id')->map(fn ($id) => (int) $id)->values()->all(); + // 幂等/防抖(最小实现):避免运营短时间内重复点击导致重复投递同一批次。 + // 说明:这里做“短 TTL 的一次性锁”,不引入新表;后续可演进为批次表 + 幂等 key。 + // key 口径:scope + filters + ids + limit(同一集合的重复点击会被拦截)。 + $lockKey = 'admin:bmpa:dispatch:' . md5($scope . '|' . $filterSummary . '|' . implode(',', $orderIds) . '|' . $limit); + if (! Cache::add($lockKey, $runId, 60)) { + return redirect()->back()->with('warning', '检测到刚刚已提交过同一批次的 BMPA 任务(1 分钟内)。为避免重复投递,本次未再次提交。'); + } + \App\Jobs\BatchMarkPaidAndActivateJob::dispatch( $orderIds, (int) $admin->id, diff --git a/tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateShouldNotDispatchDuplicateJobWithin1MinuteTest.php b/tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateShouldNotDispatchDuplicateJobWithin1MinuteTest.php new file mode 100644 index 0000000..9bd141e --- /dev/null +++ b/tests/Feature/AdminPlatformOrderBatchMarkPaidAndActivateShouldNotDispatchDuplicateJobWithin1MinuteTest.php @@ -0,0 +1,84 @@ +seed(); + + $this->post('/admin/login', [ + 'email' => 'platform.admin@demo.local', + 'password' => 'Platform@123456', + ])->assertRedirect('/admin'); + } + + public function test_should_block_duplicate_dispatch_within_1_minute(): void + { + $this->loginAsPlatformAdmin(); + + Queue::fake(); + Cache::flush(); + + $merchant = Merchant::query()->firstOrFail(); + $plan = Plan::query()->create([ + 'code' => 'batch_bmpa_dedupe_plan', + 'name' => '批量 BMPA 去重投递测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 30, + 'list_price' => 30, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BMPA_DEDUPE_0001', + 'order_type' => 'new_purchase', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 30, + 'paid_amount' => 0, + 'placed_at' => now()->subMinutes(10), + ]); + + // 第一次:应投递 + $this->post('/admin/platform-orders/batch-mark-paid-and-activate', [ + 'scope' => 'filtered', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'limit' => 50, + ])->assertRedirect(); + + // 第二次(同一集合,1 分钟内):应被阻断,不再投递 + $res2 = $this->post('/admin/platform-orders/batch-mark-paid-and-activate', [ + 'scope' => 'filtered', + 'status' => 'pending', + 'payment_status' => 'unpaid', + 'limit' => 50, + ]); + + $res2->assertRedirect(); + $res2->assertSessionHas('warning'); + + Queue::assertPushed(BatchMarkPaidAndActivateJob::class, 1); + } +}