diff --git a/app/Http/Controllers/Admin/PlatformOrderController.php b/app/Http/Controllers/Admin/PlatformOrderController.php index 305b84b..186dd15 100644 --- a/app/Http/Controllers/Admin/PlatformOrderController.php +++ b/app/Http/Controllers/Admin/PlatformOrderController.php @@ -1605,6 +1605,14 @@ class PlatformOrderController extends Controller // 注意:当前阶段仍由 Job 内逐条写 meta 与错误原因;后续可再升级为分片 Job + 结果聚合。 $orderIds = $orders->pluck('id')->map(fn ($id) => (int) $id)->values()->all(); + // 幂等/防抖(最小实现):避免运营短时间内重复点击导致重复投递同一批次。 + // 说明:这里做“短 TTL 的一次性锁”,不引入新表;后续可演进为批次表 + 幂等 key。 + // key 口径:scope + filters + ids + limit(同一集合的重复点击会被拦截)。 + $lockKey = 'admin:bas:dispatch:' . md5($scope . '|' . $filterSummary . '|' . implode(',', $orderIds) . '|' . $limit); + if (! Cache::add($lockKey, '1', 60)) { + return redirect()->back()->with('warning', '检测到刚刚已提交过同一批次的 BAS 任务(1 分钟内)。为避免重复投递,本次未再次提交。'); + } + \App\Jobs\BatchActivateSubscriptionsJob::dispatch( $orderIds, (int) $admin->id, diff --git a/tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsShouldNotDispatchDuplicateJobWithin1MinuteTest.php b/tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsShouldNotDispatchDuplicateJobWithin1MinuteTest.php new file mode 100644 index 0000000..b8cede5 --- /dev/null +++ b/tests/Feature/AdminPlatformOrderBatchActivateSubscriptionsShouldNotDispatchDuplicateJobWithin1MinuteTest.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_activate_subscriptions_dedupe_plan', + 'name' => '批量同步订阅去重投递测试套餐', + 'billing_cycle' => 'monthly', + 'price' => 10, + 'list_price' => 10, + 'status' => 'active', + 'sort' => 10, + 'published_at' => now(), + ]); + + PlatformOrder::query()->create([ + 'merchant_id' => $merchant->id, + 'plan_id' => $plan->id, + 'order_no' => 'PO_BAS_DEDUPE_0001', + 'order_type' => 'new_purchase', + 'status' => 'activated', + 'payment_status' => 'paid', + 'plan_name' => $plan->name, + 'billing_cycle' => $plan->billing_cycle, + 'period_months' => 1, + 'quantity' => 1, + 'payable_amount' => 10, + 'paid_amount' => 10, + 'placed_at' => now()->subMinutes(10), + 'paid_at' => now()->subMinutes(9), + 'activated_at' => now()->subMinutes(8), + ]); + + // 第一次:应投递 + $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + 'syncable_only' => '1', + 'limit' => 50, + ])->assertRedirect(); + + // 第二次(同一集合,1 分钟内):应被阻断,不再投递 + $res2 = $this->post('/admin/platform-orders/batch-activate-subscriptions', [ + 'scope' => 'filtered', + 'syncable_only' => '1', + 'limit' => 50, + ]); + + $res2->assertRedirect(); + $res2->assertSessionHas('warning'); + + Queue::assertPushed(BatchActivateSubscriptionsJob::class, 1); + } +}