From a06658001424acc6197ef1371c6e8978644d773e Mon Sep 17 00:00:00 2001 From: lkddi Date: Tue, 21 Apr 2026 17:53:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E8=8A=82=E6=97=A5=E7=A6=8F?= =?UTF-8?q?=E5=88=A9=E5=B9=B4=E5=BA=A6=E8=B0=83=E5=BA=A6=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E9=A2=86=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Events/HolidayEventStarted.php | 30 +- .../Admin/HolidayEventController.php | 119 +++-- app/Http/Controllers/HolidayController.php | 100 ++-- .../Requests/StoreHolidayEventRequest.php | 100 ++++ .../Requests/UpdateHolidayEventRequest.php | 14 + app/Jobs/TriggerHolidayEventJob.php | 228 +++++---- app/Models/HolidayClaim.php | 12 + app/Models/HolidayEvent.php | 36 +- app/Models/HolidayEventRun.php | 111 +++++ app/Rules/HolidayEventScheduleRule.php | 98 ++++ app/Services/HolidayEventScheduleService.php | 129 ++++++ database/factories/HolidayEventRunFactory.php | 66 +++ ...173604_create_holiday_event_runs_table.php | 64 +++ ...ter_holiday_events_for_yearly_schedule.php | 57 +++ ...1_173714_alter_holiday_claims_for_runs.php | 119 +++++ resources/js/chat.js | 175 ++++++- .../admin/holiday-events/create.blade.php | 191 +------- .../views/admin/holiday-events/edit.blade.php | 14 + .../admin/holiday-events/index.blade.php | 11 +- .../holiday-events/partials/form.blade.php | 327 +++++++++++++ .../chat/partials/holiday-modal.blade.php | 436 ++++++++++++++++-- routes/console.php | 8 + routes/web.php | 8 +- tests/Feature/HolidayControllerTest.php | 231 ++++++---- tests/Feature/HolidayEventSchedulingTest.php | 214 +++++++++ 25 files changed, 2362 insertions(+), 536 deletions(-) create mode 100644 app/Http/Requests/StoreHolidayEventRequest.php create mode 100644 app/Http/Requests/UpdateHolidayEventRequest.php create mode 100644 app/Models/HolidayEventRun.php create mode 100644 app/Rules/HolidayEventScheduleRule.php create mode 100644 app/Services/HolidayEventScheduleService.php create mode 100644 database/factories/HolidayEventRunFactory.php create mode 100644 database/migrations/2026_04_21_173604_create_holiday_event_runs_table.php create mode 100644 database/migrations/2026_04_21_173612_alter_holiday_events_for_yearly_schedule.php create mode 100644 database/migrations/2026_04_21_173714_alter_holiday_claims_for_runs.php create mode 100644 resources/views/admin/holiday-events/edit.blade.php create mode 100644 resources/views/admin/holiday-events/partials/form.blade.php create mode 100644 tests/Feature/HolidayEventSchedulingTest.php diff --git a/app/Events/HolidayEventStarted.php b/app/Events/HolidayEventStarted.php index e0f401e..846de58 100644 --- a/app/Events/HolidayEventStarted.php +++ b/app/Events/HolidayEventStarted.php @@ -14,22 +14,25 @@ namespace App\Events; -use App\Models\HolidayEvent; +use App\Models\HolidayEventRun; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +/** + * 类功能:向房间广播节日福利发放批次开始事件。 + */ class HolidayEventStarted implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; /** - * @param HolidayEvent $event 节日活动实例 + * @param HolidayEventRun $run 节日福利发放批次 */ public function __construct( - public readonly HolidayEvent $event, + public readonly HolidayEventRun $run, ) {} /** @@ -60,15 +63,18 @@ class HolidayEventStarted implements ShouldBroadcastNow public function broadcastWith(): array { return [ - 'event_id' => $this->event->id, - 'name' => $this->event->name, - 'description' => $this->event->description, - 'total_amount' => $this->event->total_amount, - 'max_claimants' => $this->event->max_claimants, - 'distribute_type' => $this->event->distribute_type, - 'fixed_amount' => $this->event->fixed_amount, - 'claimed_count' => $this->event->claimed_count, - 'expires_at' => $this->event->expires_at?->toIso8601String(), + 'run_id' => $this->run->id, + 'event_id' => $this->run->holiday_event_id, + 'name' => $this->run->event_name, + 'description' => $this->run->event_description, + 'total_amount' => $this->run->total_amount, + 'max_claimants' => $this->run->max_claimants, + 'distribute_type' => $this->run->distribute_type, + 'fixed_amount' => $this->run->fixed_amount, + 'claimed_count' => $this->run->claimed_count, + 'expires_at' => $this->run->expires_at?->toIso8601String(), + 'scheduled_for' => $this->run->scheduled_for?->toIso8601String(), + 'repeat_type' => $this->run->repeat_type, ]; } } diff --git a/app/Http/Controllers/Admin/HolidayEventController.php b/app/Http/Controllers/Admin/HolidayEventController.php index dc3c8ce..8778fe7 100644 --- a/app/Http/Controllers/Admin/HolidayEventController.php +++ b/app/Http/Controllers/Admin/HolidayEventController.php @@ -14,15 +14,27 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreHolidayEventRequest; +use App\Http\Requests\UpdateHolidayEventRequest; use App\Jobs\TriggerHolidayEventJob; use App\Models\HolidayEvent; +use App\Services\HolidayEventScheduleService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\View\View; +/** + * 类功能:管理节日福利模板的后台增删改查与手动触发操作。 + */ class HolidayEventController extends Controller { + /** + * 注入节日福利调度计算服务。 + */ + public function __construct( + private readonly HolidayEventScheduleService $scheduleService, + ) {} + /** * 节日福利活动列表页。 */ @@ -46,30 +58,9 @@ class HolidayEventController extends Controller /** * 保存新活动。 */ - public function store(Request $request): RedirectResponse + public function store(StoreHolidayEventRequest $request): RedirectResponse { - $data = $request->validate([ - 'name' => 'required|string|max:100', - 'description' => 'nullable|string|max:500', - 'total_amount' => 'required|integer|min:1', - 'max_claimants' => 'required|integer|min:0', - 'distribute_type' => 'required|in:random,fixed', - 'min_amount' => 'nullable|integer|min:1', - 'max_amount' => 'nullable|integer|min:1', - 'fixed_amount' => 'nullable|integer|min:1', - 'send_at' => 'required|date', - 'expire_minutes' => 'required|integer|min:1|max:1440', - 'repeat_type' => 'required|in:once,daily,weekly,monthly,cron', - 'cron_expr' => 'nullable|string|max:100', - 'target_type' => 'required|in:all,vip,level', - 'target_value' => 'nullable|string|max:50', - 'enabled' => 'boolean', - ]); - - $data['status'] = 'pending'; - $data['enabled'] = $request->boolean('enabled', true); - - HolidayEvent::create($data); + HolidayEvent::create($this->buildPayload($request->validated(), true)); return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!'); } @@ -85,26 +76,9 @@ class HolidayEventController extends Controller /** * 更新活动。 */ - public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse + public function update(UpdateHolidayEventRequest $request, HolidayEvent $holidayEvent): RedirectResponse { - $data = $request->validate([ - 'name' => 'required|string|max:100', - 'description' => 'nullable|string|max:500', - 'total_amount' => 'required|integer|min:1', - 'max_claimants' => 'required|integer|min:0', - 'distribute_type' => 'required|in:random,fixed', - 'min_amount' => 'nullable|integer|min:1', - 'max_amount' => 'nullable|integer|min:1', - 'fixed_amount' => 'nullable|integer|min:1', - 'send_at' => 'required|date', - 'expire_minutes' => 'required|integer|min:1|max:1440', - 'repeat_type' => 'required|in:once,daily,weekly,monthly,cron', - 'cron_expr' => 'nullable|string|max:100', - 'target_type' => 'required|in:all,vip,level', - 'target_value' => 'nullable|string|max:50', - ]); - - $holidayEvent->update($data); + $holidayEvent->update($this->buildPayload($request->validated())); return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!'); } @@ -128,13 +102,12 @@ class HolidayEventController extends Controller */ public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse { - if ($holidayEvent->status !== 'pending') { - return back()->with('error', '只有待触发状态的活动才能手动触发。'); + if (! $holidayEvent->enabled || $holidayEvent->status === 'cancelled') { + return back()->with('error', '当前活动未启用或已取消,不能立即触发。'); } - // 设置触发时间为当前,立即入队 - $holidayEvent->update(['send_at' => now()]); - TriggerHolidayEventJob::dispatch($holidayEvent); + // 立即触发只生成临时批次,不覆盖年度锚点或下次计划时间。 + TriggerHolidayEventJob::dispatch($holidayEvent, true); return back()->with('success', '活动已触发,请稍后刷新查看状态。'); } @@ -148,4 +121,54 @@ class HolidayEventController extends Controller return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。'); } + + /** + * 组装节日福利模板的可持久化字段。 + * + * @param array $data + * @return array + */ + private function buildPayload(array $data, bool $isCreating = false): array + { + $payload = $data; + + // 创建与编辑都统一回收无效字段,避免模板状态互相污染。 + if (($payload['distribute_type'] ?? 'random') === 'random') { + $payload['fixed_amount'] = null; + } else { + $payload['min_amount'] = 1; + $payload['max_amount'] = null; + } + + if (($payload['target_type'] ?? 'all') !== 'level') { + $payload['target_value'] = null; + } + + if (($payload['repeat_type'] ?? 'once') !== 'cron') { + $payload['cron_expr'] = null; + } + + if (($payload['repeat_type'] ?? 'once') === 'yearly') { + $payload['send_at'] = $this->scheduleService + ->resolveNextConfiguredSendAt($payload) + ->toDateTimeString(); + } else { + $payload['schedule_month'] = null; + $payload['schedule_day'] = null; + $payload['schedule_time'] = null; + $payload['duration_days'] = 1; + $payload['daily_occurrences'] = 1; + $payload['occurrence_interval_minutes'] = null; + } + + // 每次保存模板时,都让系统按新配置重新进入待触发状态。 + $payload['status'] = 'pending'; + $payload['enabled'] = (bool) ($payload['enabled'] ?? true); + $payload['triggered_at'] = null; + $payload['expires_at'] = null; + $payload['claimed_count'] = 0; + $payload['claimed_amount'] = 0; + + return $payload; + } } diff --git a/app/Http/Controllers/HolidayController.php b/app/Http/Controllers/HolidayController.php index 912b701..69982e3 100644 --- a/app/Http/Controllers/HolidayController.php +++ b/app/Http/Controllers/HolidayController.php @@ -15,14 +15,20 @@ namespace App\Http\Controllers; use App\Enums\CurrencySource; use App\Models\HolidayClaim; -use App\Models\HolidayEvent; +use App\Models\HolidayEventRun; use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +/** + * 类功能:处理节日福利批次的前台领取与状态查询。 + */ class HolidayController extends Controller { + /** + * 注入用户金币服务。 + */ public function __construct( private readonly UserCurrencyService $currency, ) {} @@ -30,56 +36,72 @@ class HolidayController extends Controller /** * 用户领取节日福利红包。 * - * 从 holiday_claims 中查找当前用户的待领取记录, - * 入账金币并更新活动统计数据。 + * 从 holiday_claims 中查找当前用户在指定批次下的待领取记录, + * 入账金币并更新批次统计数据。 */ - public function claim(Request $request, HolidayEvent $event): JsonResponse + public function claim(Request $request, HolidayEventRun $run): JsonResponse { $user = $request->user(); - // 活动是否在领取有效期内 - if (! $event->isClaimable()) { + // 批次是否在领取有效期内。 + if (! $run->isClaimable()) { return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']); } - // 查找该用户的领取记录(批量插入时已生成) - $claim = HolidayClaim::query() - ->where('event_id', $event->id) - ->where('user_id', $user->id) - ->lockForUpdate() - ->first(); + return DB::transaction(function () use ($run, $user): JsonResponse { + /** @var HolidayEventRun|null $lockedRun */ + $lockedRun = HolidayEventRun::query() + ->whereKey($run->id) + ->lockForUpdate() + ->first(); - if (! $claim) { - return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']); - } + if (! $lockedRun || ! $lockedRun->isClaimable()) { + return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']); + } - // 防止重复领取(claimed_at 为 null 表示未领取) - // 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段 - // 这里用数据库唯一约束保障幂等性:直接返回已领取的提示 - return DB::transaction(function () use ($event, $claim, $user): JsonResponse { - // 金币入账 + /** @var HolidayClaim|null $claim */ + $claim = HolidayClaim::query() + ->where('run_id', $lockedRun->id) + ->where('user_id', $user->id) + ->lockForUpdate() + ->first(); + + if (! $claim) { + return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']); + } + + // claimed_at 不为空代表本轮已领过,直接返回幂等提示。 + if ($claim->claimed_at !== null) { + return response()->json([ + 'ok' => false, + 'message' => '您已领取过本轮福利。', + 'amount' => $claim->amount, + ]); + } + + // 金币入账。 $this->currency->change( $user, 'gold', $claim->amount, CurrencySource::HOLIDAY_BONUS, - "节日福利:{$event->name}", + "节日福利:{$lockedRun->event_name}", ); - // 更新活动统计(只在首次领取时) - HolidayEvent::query() - ->where('id', $event->id) - ->increment('claimed_amount', $claim->amount); + // 领取成功后只更新 claimed_at,不再删除记录,便于幂等和历史追踪。 + $claim->update(['claimed_at' => now()]); - // 删除领取记录(以此标记"已领取",防止重复调用) - $claim->delete(); + // 批次领取统计按成功领取次数累计。 + $lockedRun->increment('claimed_count'); + $lockedRun->increment('claimed_amount', $claim->amount); - // 检查是否已全部领完 - if ($event->max_claimants > 0) { - $remaining = HolidayClaim::where('event_id', $event->id)->count(); - if ($remaining === 0) { - $event->update(['status' => 'completed']); - } + $remainingPendingClaims = HolidayClaim::query() + ->where('run_id', $lockedRun->id) + ->whereNull('claimed_at') + ->count(); + + if ($remainingPendingClaims === 0) { + $lockedRun->update(['status' => 'completed']); } return response()->json([ @@ -91,21 +113,23 @@ class HolidayController extends Controller } /** - * 查询当前用户在指定活动中的待领取状态。 + * 查询当前用户在指定批次中的待领取状态。 */ - public function status(Request $request, HolidayEvent $event): JsonResponse + public function status(Request $request, HolidayEventRun $run): JsonResponse { $user = $request->user(); $claim = HolidayClaim::query() - ->where('event_id', $event->id) + ->where('run_id', $run->id) ->where('user_id', $user->id) ->first(); return response()->json([ - 'claimable' => $claim !== null && $event->isClaimable(), + 'claimable' => $claim !== null && $claim->claimed_at === null && $run->isClaimable(), + 'claimed' => $claim?->claimed_at !== null, 'amount' => $claim?->amount ?? 0, - 'expires_at' => $event->expires_at?->toIso8601String(), + 'status' => $run->status, + 'expires_at' => $run->expires_at?->toIso8601String(), ]); } } diff --git a/app/Http/Requests/StoreHolidayEventRequest.php b/app/Http/Requests/StoreHolidayEventRequest.php new file mode 100644 index 0000000..38fa5e5 --- /dev/null +++ b/app/Http/Requests/StoreHolidayEventRequest.php @@ -0,0 +1,100 @@ +user() !== null; + } + + /** + * 预处理布尔字段,避免浏览器复选框值造成类型偏差。 + */ + protected function prepareForValidation(): void + { + if ($this->has('enabled')) { + $this->merge([ + 'enabled' => $this->boolean('enabled'), + ]); + } + } + + /** + * 获取节日福利模板的字段校验规则。 + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:500'], + 'total_amount' => ['required', 'integer', 'min:1'], + 'max_claimants' => ['required', 'integer', 'min:0'], + 'distribute_type' => ['required', Rule::in(['random', 'fixed'])], + 'min_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,random'], + 'max_amount' => ['nullable', 'integer', 'min:1', 'gte:min_amount'], + 'fixed_amount' => ['nullable', 'integer', 'min:1', 'required_if:distribute_type,fixed'], + 'send_at' => ['nullable', 'date', Rule::requiredIf(fn (): bool => $this->input('repeat_type') !== 'yearly')], + 'expire_minutes' => ['required', 'integer', 'min:1', 'max:1440'], + 'repeat_type' => [ + 'required', + Rule::in(['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly']), + new HolidayEventScheduleRule, + ], + 'cron_expr' => ['nullable', 'string', 'max:100', 'required_if:repeat_type,cron'], + 'schedule_month' => ['nullable', 'integer', 'between:1,12'], + 'schedule_day' => ['nullable', 'integer', 'between:1,31'], + 'schedule_time' => ['nullable', 'date_format:H:i'], + 'duration_days' => ['nullable', 'integer', 'min:1', 'max:31'], + 'daily_occurrences' => ['nullable', 'integer', 'min:1', 'max:24'], + 'occurrence_interval_minutes' => ['nullable', 'integer', 'min:1', 'max:1439'], + 'target_type' => ['required', Rule::in(['all', 'vip', 'level'])], + 'target_value' => ['nullable', 'string', 'max:50', 'required_if:target_type,level'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + /** + * 获取中文错误提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => '请输入活动名称', + 'total_amount.required' => '请填写总金币奖池', + 'max_claimants.required' => '请填写可领取人数上限', + 'distribute_type.required' => '请选择分配方式', + 'min_amount.required_if' => '随机分配模式下必须填写最低保底金额', + 'fixed_amount.required_if' => '定额发放模式下必须填写每人固定金额', + 'send_at.required' => '请选择触发时间', + 'expire_minutes.required' => '请填写领取有效期', + 'repeat_type.required' => '请选择重复方式', + 'cron_expr.required_if' => 'CRON 模式下必须填写表达式', + 'schedule_time.date_format' => '首轮开始时间格式必须为 HH:ii', + 'target_type.required' => '请选择目标用户范围', + 'target_value.required_if' => '指定等级以上模式下必须填写最低等级', + ]; + } +} diff --git a/app/Http/Requests/UpdateHolidayEventRequest.php b/app/Http/Requests/UpdateHolidayEventRequest.php new file mode 100644 index 0000000..c488e0d --- /dev/null +++ b/app/Http/Requests/UpdateHolidayEventRequest.php @@ -0,0 +1,14 @@ +event->fresh(); + $run = $this->prepareRun($scheduleService); - // 防止重复触发 - if (! $event || $event->status !== 'pending') { + if (! $run) { return; } - $now = now(); - $expiresAt = $now->copy()->addMinutes($event->expire_minutes); - - // 先标记为 active,防止并发重复触发 - $updated = HolidayEvent::query() - ->where('id', $event->id) - ->where('status', 'pending') - ->update([ - 'status' => 'active', - 'triggered_at' => $now, - 'expires_at' => $expiresAt, - ]); - - if (! $updated) { - return; // 已被其他进程触发 - } - - $event->refresh(); - - // 获取在线用户(满足 target_type 条件) - $onlineIds = $this->getEligibleOnlineUsers($event, $chatState); + // 获取在线用户(满足目标用户条件),每一轮都按触发当时的在线状态重新计算。 + $onlineIds = $this->getEligibleOnlineUsers($run); if (empty($onlineIds)) { - // 无合格在线用户,直接标记完成 - $event->update(['status' => 'completed']); + $run->update(['status' => 'completed', 'audience_count' => 0]); return; } - // 按 max_claimants 限制人数 - if ($event->max_claimants > 0 && count($onlineIds) > $event->max_claimants) { + // 按本批次快照中的 max_claimants 限制人数。 + if ($run->max_claimants > 0 && count($onlineIds) > $run->max_claimants) { shuffle($onlineIds); - $onlineIds = array_slice($onlineIds, 0, $event->max_claimants); + $onlineIds = array_slice($onlineIds, 0, $run->max_claimants); } - // 计算每人金额 - $amounts = $this->distributeAmounts($event, count($onlineIds)); + // 计算每人的待领取金额。 + $amounts = $this->distributeAmounts($run, count($onlineIds)); - DB::transaction(function () use ($event, $onlineIds, $amounts, $now) { + DB::transaction(function () use ($run, $onlineIds, $amounts): void { $claims = []; foreach ($onlineIds as $i => $userId) { $claims[] = [ - 'event_id' => $event->id, + 'event_id' => $run->holiday_event_id, + 'run_id' => $run->id, 'user_id' => $userId, 'amount' => $amounts[$i] ?? 0, - 'claimed_at' => $now, + 'claimed_at' => null, ]; } - // 批量插入领取记录 + // 一次性生成本轮全部待领取记录,claimed_at 默认为 null。 HolidayClaim::insert($claims); + + $run->update(['audience_count' => count($claims)]); }); - // 广播全房间 WebSocket 事件 - broadcast(new HolidayEventStarted($event->refresh())); + // 广播本轮发放批次,前端将基于 run_id 领取。 + broadcast(new HolidayEventStarted($run->fresh())); - // 向聊天室追加系统消息(写入 Redis + 落库) - $this->pushSystemMessage($event, count($onlineIds), $chatState); - - // 处理重复活动(计算下次触发时间) - $this->scheduleNextRepeat($event); + // 向聊天室追加系统公告,提醒用户点击弹窗领取。 + $this->pushSystemMessage($run, count($onlineIds), $chatState); } /** - * 获取满足条件的在线用户 ID 列表。 + * 生成批次并推进模板到下一次触发时间。 + */ + private function prepareRun(HolidayEventScheduleService $scheduleService): ?HolidayEventRun + { + /** @var HolidayEventRun|null $run */ + $run = DB::transaction(function () use ($scheduleService): ?HolidayEventRun { + /** @var HolidayEvent|null $event */ + $event = HolidayEvent::query() + ->whereKey($this->event->id) + ->lockForUpdate() + ->first(); + + if (! $event || ! $event->enabled || $event->status === 'cancelled') { + return null; + } + + $now = now(); + $scheduledFor = $this->manual ? $now->copy() : $event->send_at; + + if (! $this->manual) { + // 定时触发只允许处理真正到期且仍处于 pending 的模板。 + if ($event->status !== 'pending' || $scheduledFor === null || $scheduledFor->isFuture()) { + 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), + 'claimed_count' => 0, + 'claimed_amount' => 0, + ]); + } else { + // 手动立即触发只更新最后触发信息,不改动既有 send_at 锚点。 + $event->update([ + 'triggered_at' => $now, + 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), + ]); + } + + return HolidayEventRun::query()->create([ + 'holiday_event_id' => $event->id, + 'event_name' => $event->name, + 'event_description' => $event->description, + 'total_amount' => $event->total_amount, + 'max_claimants' => $event->max_claimants, + 'distribute_type' => $event->distribute_type, + 'min_amount' => $event->min_amount, + 'max_amount' => $event->max_amount, + 'fixed_amount' => $event->fixed_amount, + 'target_type' => $event->target_type, + 'target_value' => $event->target_value, + 'repeat_type' => $event->repeat_type, + 'scheduled_for' => $scheduledFor, + 'triggered_at' => $now, + 'expires_at' => $now->copy()->addMinutes($event->expire_minutes), + 'status' => 'active', + 'audience_count' => 0, + 'claimed_count' => 0, + 'claimed_amount' => 0, + ]); + }); + + return $run; + } + + /** + * 获取满足当前批次条件的在线用户 ID 列表。 * * @return array */ - private function getEligibleOnlineUsers(HolidayEvent $event, ChatStateService $chatState): array + private function getEligibleOnlineUsers(HolidayEventRun $run): array { try { - $key = 'room:1:users'; - $users = Redis::hgetall($key); + $users = Redis::hgetall('room:1:users'); if (empty($users)) { return []; } - $usernames = array_keys($users); - - // 根据 user_id 从 Redis value 或数据库查出 ID $ids = []; - $fallbacks = []; + $fallbackUsernames = []; foreach ($users as $username => $jsonInfo) { $info = json_decode($jsonInfo, true); + if (isset($info['user_id'])) { $ids[] = (int) $info['user_id']; - } else { - $fallbacks[] = $username; + + continue; } + + $fallbackUsernames[] = $username; } - if (! empty($fallbacks)) { - $dbIds = User::whereIn('username', $fallbacks)->pluck('id')->map(fn ($id) => (int) $id)->all(); - $ids = array_merge($ids, $dbIds); + if (! empty($fallbackUsernames)) { + $fallbackIds = User::query() + ->whereIn('username', $fallbackUsernames) + ->pluck('id') + ->map(fn ($id): int => (int) $id) + ->all(); + + $ids = array_merge($ids, $fallbackIds); } $ids = array_values(array_unique($ids)); - // 根据 target_type 过滤 - return match ($event->target_type) { + // 目标用户范围以当前批次快照为准,避免模板后续编辑影响本轮名单。 + return match ($run->target_type) { 'vip' => User::whereIn('id', $ids)->whereNotNull('vip_level_id')->pluck('id')->map(fn ($id) => (int) $id)->all(), - 'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($event->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(), + 'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($run->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(), default => $ids, }; } catch (\Throwable) { @@ -172,23 +230,23 @@ class TriggerHolidayEventJob implements ShouldQueue * * @return array */ - private function distributeAmounts(HolidayEvent $event, int $count): array + private function distributeAmounts(HolidayEventRun $run, int $count): array { if ($count <= 0) { return []; } - if ($event->distribute_type === 'fixed') { - // 定额模式:每人相同金额 - $amount = $event->fixed_amount ?? (int) floor($event->total_amount / $count); + if ($run->distribute_type === 'fixed') { + // 定额模式:每人固定一个金额,优先使用模板快照中的 fixed_amount。 + $amount = $run->fixed_amount ?? (int) floor($run->total_amount / $count); return array_fill(0, $count, $amount); } - // 随机模式:二倍均值算法 - $total = $event->total_amount; - $min = max(1, $event->min_amount ?? 1); - $max = $event->max_amount ?? (int) ceil($total * 2 / $count); + // 随机模式沿用二倍均值算法,保证总金额恰好发完。 + $total = $run->total_amount; + $min = max(1, $run->min_amount ?? 1); + $max = $run->max_amount ?? (int) ceil($total * 2 / $count); $amounts = []; $remaining = $total; @@ -210,10 +268,10 @@ class TriggerHolidayEventJob implements ShouldQueue /** * 向聊天室推送系统公告消息并写入 Redis + 落库。 */ - private function pushSystemMessage(HolidayEvent $event, int $claimCount, ChatStateService $chatState): void + private function pushSystemMessage(HolidayEventRun $run, int $claimCount, ChatStateService $chatState): void { - $typeLabel = $event->distribute_type === 'fixed' ? "每人固定 {$event->fixed_amount} 金币" : '随机分配'; - $content = "🎊 【{$event->name}】节日福利开始啦!总奖池 💰".number_format($event->total_amount) + $typeLabel = $run->distribute_type === 'fixed' ? "每人固定 {$run->fixed_amount} 金币" : '随机分配'; + $content = "🎊 【{$run->event_name}】节日福利开始啦!总奖池 💰".number_format($run->total_amount) ." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!"; $msg = [ @@ -232,30 +290,4 @@ class TriggerHolidayEventJob implements ShouldQueue broadcast(new MessageSent(1, $msg)); SaveMessageJob::dispatch($msg); } - - /** - * 处理重复活动:计算下次触发时间并重置状态。 - */ - private function scheduleNextRepeat(HolidayEvent $event): void - { - $nextSendAt = match ($event->repeat_type) { - 'daily' => $event->send_at->copy()->addDay(), - 'weekly' => $event->send_at->copy()->addWeek(), - 'monthly' => $event->send_at->copy()->addMonth(), - default => null, // 'once' 或 'cron' 不自动重复 - }; - - if ($nextSendAt) { - $event->update([ - 'status' => 'pending', - 'send_at' => $nextSendAt, - 'triggered_at' => null, - 'expires_at' => null, - 'claimed_count' => 0, - 'claimed_amount' => 0, - ]); - } else { - $event->update(['status' => 'completed']); - } - } } diff --git a/app/Models/HolidayClaim.php b/app/Models/HolidayClaim.php index 34d72d9..e878729 100644 --- a/app/Models/HolidayClaim.php +++ b/app/Models/HolidayClaim.php @@ -15,12 +15,16 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * 类功能:记录用户在某个节日福利发放批次中的领取状态。 + */ class HolidayClaim extends Model { public $timestamps = false; protected $fillable = [ 'event_id', + 'run_id', 'user_id', 'amount', 'claimed_at', @@ -45,6 +49,14 @@ class HolidayClaim extends Model return $this->belongsTo(HolidayEvent::class, 'event_id'); } + /** + * 关联所属发放批次。 + */ + public function run(): BelongsTo + { + return $this->belongsTo(HolidayEventRun::class, 'run_id'); + } + /** * 关联领取用户。 */ diff --git a/app/Models/HolidayEvent.php b/app/Models/HolidayEvent.php index e478670..5866db9 100644 --- a/app/Models/HolidayEvent.php +++ b/app/Models/HolidayEvent.php @@ -13,11 +13,17 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +/** + * 类功能:定义节日福利模板及其调度配置。 + */ class HolidayEvent extends Model { + use HasFactory; + protected $fillable = [ 'name', 'description', @@ -31,6 +37,12 @@ class HolidayEvent extends Model 'expire_minutes', 'repeat_type', 'cron_expr', + 'schedule_month', + 'schedule_day', + 'schedule_time', + 'duration_days', + 'daily_occurrences', + 'occurrence_interval_minutes', 'target_type', 'target_value', 'status', @@ -57,6 +69,11 @@ class HolidayEvent extends Model 'max_amount' => 'integer', 'fixed_amount' => 'integer', 'expire_minutes' => 'integer', + 'schedule_month' => 'integer', + 'schedule_day' => 'integer', + 'duration_days' => 'integer', + 'daily_occurrences' => 'integer', + 'occurrence_interval_minutes' => 'integer', 'claimed_count' => 'integer', 'claimed_amount' => 'integer', ]; @@ -71,25 +88,19 @@ class HolidayEvent extends Model } /** - * 判断活动是否在领取有效期内。 + * 本模板对应的所有发放批次。 */ - public function isClaimable(): bool + public function runs(): HasMany { - return $this->status === 'active' - && $this->expires_at - && $this->expires_at->isFuture(); + return $this->hasMany(HolidayEventRun::class, 'holiday_event_id'); } /** - * 判断是否还有剩余领取名额。 + * 判断模板是否使用年度节日调度。 */ - public function hasQuota(): bool + public function usesYearlySchedule(): bool { - if ($this->max_claimants === 0) { - return true; // 不限人数 - } - - return $this->claimed_count < $this->max_claimants; + return $this->repeat_type === 'yearly'; } /** @@ -100,6 +111,7 @@ class HolidayEvent extends Model return static::query() ->where('status', 'pending') ->where('enabled', true) + ->whereNotNull('send_at') ->where('send_at', '<=', now()) ->get(); } diff --git a/app/Models/HolidayEventRun.php b/app/Models/HolidayEventRun.php new file mode 100644 index 0000000..073f0e5 --- /dev/null +++ b/app/Models/HolidayEventRun.php @@ -0,0 +1,111 @@ + */ + use HasFactory; + + /** + * 允许批量赋值的字段。 + * + * @var array + */ + protected $fillable = [ + 'holiday_event_id', + 'event_name', + 'event_description', + 'total_amount', + 'max_claimants', + 'distribute_type', + 'min_amount', + 'max_amount', + 'fixed_amount', + 'target_type', + 'target_value', + 'repeat_type', + 'scheduled_for', + 'triggered_at', + 'expires_at', + 'status', + 'audience_count', + 'claimed_count', + 'claimed_amount', + ]; + + /** + * 属性类型转换。 + * + * @return array + */ + protected function casts(): array + { + return [ + 'scheduled_for' => 'datetime', + 'triggered_at' => 'datetime', + 'expires_at' => 'datetime', + 'total_amount' => 'integer', + 'max_claimants' => 'integer', + 'min_amount' => 'integer', + 'max_amount' => 'integer', + 'fixed_amount' => 'integer', + 'audience_count' => 'integer', + 'claimed_count' => 'integer', + 'claimed_amount' => 'integer', + ]; + } + + /** + * 关联所属节日福利模板。 + */ + public function holidayEvent(): BelongsTo + { + return $this->belongsTo(HolidayEvent::class, 'holiday_event_id'); + } + + /** + * 关联本批次的领取记录。 + */ + public function claims(): HasMany + { + return $this->hasMany(HolidayClaim::class, 'run_id'); + } + + /** + * 判断当前批次是否仍处于可领取状态。 + */ + public function isClaimable(): bool + { + return $this->status === 'active' + && $this->expires_at !== null + && $this->expires_at->isFuture(); + } + + /** + * 查询需要被自动收尾的批次。 + */ + public static function pendingToExpire(): \Illuminate\Database\Eloquent\Collection + { + return static::query() + ->where('status', 'active') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->get(); + } +} diff --git a/app/Rules/HolidayEventScheduleRule.php b/app/Rules/HolidayEventScheduleRule.php new file mode 100644 index 0000000..3e7d7f3 --- /dev/null +++ b/app/Rules/HolidayEventScheduleRule.php @@ -0,0 +1,98 @@ + + */ + protected array $data = []; + + /** + * 注入待校验的完整数据集。 + * + * @param array $data + */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + /** + * 运行年度调度规则校验。 + * + * @param Closure(string, ?string=): PotentiallyTranslatedString $fail + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $repeatType = (string) ($this->data['repeat_type'] ?? $value ?? ''); + + if ($repeatType !== 'yearly') { + return; + } + + $month = (int) ($this->data['schedule_month'] ?? 0); + $day = (int) ($this->data['schedule_day'] ?? 0); + $time = (string) ($this->data['schedule_time'] ?? ''); + $durationDays = (int) ($this->data['duration_days'] ?? 0); + $dailyOccurrences = (int) ($this->data['daily_occurrences'] ?? 0); + $intervalMinutes = $this->data['occurrence_interval_minutes']; + + if ($month < 1 || $month > 12) { + $fail('年度节日模式必须设置有效的触发月份。'); + } + + if ($day < 1 || $day > 31) { + $fail('年度节日模式必须设置有效的触发日期。'); + } + + if ($month > 0 && $day > 0 && ! checkdate($month, $day, 2024)) { + $fail('所选月份和日期不是有效的公历节日日期。'); + } + + if (! preg_match('/^\d{2}:\d{2}$/', $time)) { + $fail('年度节日模式必须设置首轮开始时间。'); + + return; + } + + if ($durationDays < 1 || $durationDays > 31) { + $fail('连续发放天数必须在 1 到 31 天之间。'); + } + + if ($dailyOccurrences < 1 || $dailyOccurrences > 24) { + $fail('每天发送次数必须在 1 到 24 次之间。'); + } + + if ($dailyOccurrences > 1 && ((int) $intervalMinutes) < 1) { + $fail('同一天多轮发送时,必须设置大于 0 的间隔分钟数。'); + } + + [$hour, $minute] = array_map('intval', explode(':', $time)); + $startMinutes = $hour * 60 + $minute; + $lastOffsetMinutes = max(0, ($dailyOccurrences - 1) * (int) ($intervalMinutes ?? 0)); + + if ($startMinutes + $lastOffsetMinutes >= 1440) { + $fail('同一天多轮发送的最后一轮不能跨到次日,请缩短间隔或减少次数。'); + } + } +} diff --git a/app/Services/HolidayEventScheduleService.php b/app/Services/HolidayEventScheduleService.php new file mode 100644 index 0000000..7e07408 --- /dev/null +++ b/app/Services/HolidayEventScheduleService.php @@ -0,0 +1,129 @@ + $data + */ + public function resolveNextConfiguredSendAt(array $data, ?CarbonInterface $reference = null): CarbonImmutable + { + $referenceTime = CarbonImmutable::instance($reference ?? now()); + + if (($data['repeat_type'] ?? 'once') !== 'yearly') { + return CarbonImmutable::parse((string) $data['send_at']); + } + + return $this->findNextYearlyOccurrence($data, $referenceTime); + } + + /** + * 计算模板在一次自动触发后的下一次 send_at。 + */ + public function advanceAfterTrigger(HolidayEvent $event): ?CarbonImmutable + { + if ($event->send_at === null) { + return null; + } + + $currentSendAt = CarbonImmutable::instance($event->send_at); + + return match ($event->repeat_type) { + 'daily' => $currentSendAt->addDay(), + 'weekly' => $currentSendAt->addWeek(), + 'monthly' => $currentSendAt->addMonth(), + 'yearly' => $this->findNextYearlyOccurrence($this->extractYearlyConfig($event), $currentSendAt->addSecond()), + default => null, + }; + } + + /** + * 查找年度节日配置在参考时间之后的下一次触发点。 + * + * @param array $data + */ + public function findNextYearlyOccurrence(array $data, CarbonInterface $reference): CarbonImmutable + { + $referenceTime = CarbonImmutable::instance($reference); + + foreach ([$referenceTime->year, $referenceTime->year + 1, $referenceTime->year + 2] as $year) { + foreach ($this->buildYearlyOccurrencesForYear($data, $year) as $occurrence) { + if ($occurrence->greaterThanOrEqualTo($referenceTime)) { + return $occurrence; + } + } + } + + return $this->buildYearlyOccurrencesForYear($data, $referenceTime->year + 3)[0]; + } + + /** + * 构造指定年份内的全部年度节日触发点。 + * + * @param array $data + * @return array + */ + public function buildYearlyOccurrencesForYear(array $data, int $year): array + { + [$hour, $minute] = array_map('intval', explode(':', (string) $data['schedule_time'])); + + $baseDate = CarbonImmutable::create( + $year, + (int) $data['schedule_month'], + (int) $data['schedule_day'], + $hour, + $minute, + 0, + config('app.timezone') + ); + + $occurrences = []; + $durationDays = max(1, (int) ($data['duration_days'] ?? 1)); + $dailyOccurrences = max(1, (int) ($data['daily_occurrences'] ?? 1)); + $intervalMinutes = (int) ($data['occurrence_interval_minutes'] ?? 0); + + for ($dayIndex = 0; $dayIndex < $durationDays; $dayIndex++) { + $dayStart = $baseDate->addDays($dayIndex); + + for ($occurrenceIndex = 0; $occurrenceIndex < $dailyOccurrences; $occurrenceIndex++) { + $occurrences[] = $dayStart->addMinutes($intervalMinutes * $occurrenceIndex); + } + } + + return $occurrences; + } + + /** + * 从模板模型中提取年度节日调度字段。 + * + * @return array + */ + private function extractYearlyConfig(HolidayEvent $event): array + { + return [ + 'schedule_month' => $event->schedule_month, + 'schedule_day' => $event->schedule_day, + 'schedule_time' => $event->schedule_time, + 'duration_days' => $event->duration_days, + 'daily_occurrences' => $event->daily_occurrences, + 'occurrence_interval_minutes' => $event->occurrence_interval_minutes, + ]; + } +} diff --git a/database/factories/HolidayEventRunFactory.php b/database/factories/HolidayEventRunFactory.php new file mode 100644 index 0000000..13b56ee --- /dev/null +++ b/database/factories/HolidayEventRunFactory.php @@ -0,0 +1,66 @@ + + */ +class HolidayEventRunFactory extends Factory +{ + /** + * 定义默认批次测试数据。 + * + * @return array + */ + public function definition(): array + { + $event = HolidayEvent::query()->create([ + 'name' => '节日福利测试模板', + 'description' => '用于测试的节日福利模板', + 'total_amount' => 6000, + 'max_claimants' => 10, + 'distribute_type' => 'fixed', + 'min_amount' => 100, + 'max_amount' => 1000, + 'fixed_amount' => 600, + 'send_at' => now(), + 'expire_minutes' => 30, + 'repeat_type' => 'once', + 'target_type' => 'all', + 'status' => 'pending', + 'enabled' => true, + ]); + + return [ + 'holiday_event_id' => $event->id, + 'event_name' => $event->name, + 'event_description' => $event->description, + 'total_amount' => $event->total_amount, + 'max_claimants' => $event->max_claimants, + 'distribute_type' => $event->distribute_type, + 'min_amount' => $event->min_amount, + 'max_amount' => $event->max_amount, + 'fixed_amount' => $event->fixed_amount, + 'target_type' => $event->target_type, + 'target_value' => $event->target_value, + 'repeat_type' => $event->repeat_type, + 'scheduled_for' => now(), + 'triggered_at' => now(), + 'expires_at' => now()->addMinutes(30), + 'status' => 'active', + 'audience_count' => 0, + 'claimed_count' => 0, + 'claimed_amount' => 0, + ]; + } +} diff --git a/database/migrations/2026_04_21_173604_create_holiday_event_runs_table.php b/database/migrations/2026_04_21_173604_create_holiday_event_runs_table.php new file mode 100644 index 0000000..7547867 --- /dev/null +++ b/database/migrations/2026_04_21_173604_create_holiday_event_runs_table.php @@ -0,0 +1,64 @@ +id(); + $table->unsignedBigInteger('holiday_event_id')->comment('所属节日福利模板 ID'); + + // 批次快照:避免模板后续被修改后影响历史记录展示。 + $table->string('event_name', 100)->comment('活动名称快照'); + $table->text('event_description')->nullable()->comment('活动描述快照'); + $table->unsignedInteger('total_amount')->comment('总金币奖池快照'); + $table->unsignedInteger('max_claimants')->default(0)->comment('本批次最大领取人数(0=不限)'); + $table->enum('distribute_type', ['random', 'fixed'])->default('random')->comment('分配方式快照'); + $table->unsignedInteger('min_amount')->default(1)->comment('随机模式最低金额快照'); + $table->unsignedInteger('max_amount')->nullable()->comment('随机模式单人上限快照'); + $table->unsignedInteger('fixed_amount')->nullable()->comment('定额模式单人金额快照'); + $table->enum('target_type', ['all', 'vip', 'level'])->default('all')->comment('目标用户类型快照'); + $table->string('target_value', 50)->nullable()->comment('目标用户条件快照'); + $table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])->default('once')->comment('模板重复方式快照'); + + // 批次状态与时间。 + $table->dateTime('scheduled_for')->comment('本批次原定触发时间'); + $table->dateTime('triggered_at')->comment('本批次实际触发时间'); + $table->dateTime('expires_at')->nullable()->comment('本批次领取截止时间'); + $table->enum('status', ['active', 'completed', 'expired', 'cancelled'])->default('active')->comment('批次状态'); + + // 统计信息。 + $table->unsignedInteger('audience_count')->default(0)->comment('本批次待领取总人数'); + $table->unsignedInteger('claimed_count')->default(0)->comment('本批次已领取人数'); + $table->unsignedInteger('claimed_amount')->default(0)->comment('本批次已领取金币总额'); + + $table->timestamps(); + + $table->foreign('holiday_event_id')->references('id')->on('holiday_events')->cascadeOnDelete(); + $table->index(['status', 'expires_at']); + $table->index(['holiday_event_id', 'scheduled_for']); + }); + } + + /** + * 回滚 holiday_event_runs 表。 + */ + public function down(): void + { + Schema::dropIfExists('holiday_event_runs'); + } +}; diff --git a/database/migrations/2026_04_21_173612_alter_holiday_events_for_yearly_schedule.php b/database/migrations/2026_04_21_173612_alter_holiday_events_for_yearly_schedule.php new file mode 100644 index 0000000..80d26a6 --- /dev/null +++ b/database/migrations/2026_04_21_173612_alter_holiday_events_for_yearly_schedule.php @@ -0,0 +1,57 @@ +tinyInteger('schedule_month')->unsigned()->nullable()->after('repeat_type')->comment('年度节日触发月份'); + $table->tinyInteger('schedule_day')->unsigned()->nullable()->after('schedule_month')->comment('年度节日触发日'); + $table->string('schedule_time', 5)->nullable()->after('schedule_day')->comment('年度节日首轮开始时间(HH:ii)'); + $table->unsignedSmallInteger('duration_days')->default(1)->after('schedule_time')->comment('连续发放天数'); + $table->unsignedSmallInteger('daily_occurrences')->default(1)->after('duration_days')->comment('每天触发次数'); + $table->unsignedSmallInteger('occurrence_interval_minutes')->nullable()->after('daily_occurrences')->comment('同一天多轮发送间隔(分钟)'); + }); + + Schema::table('holiday_events', function (Blueprint $table) { + // Laravel 12 / MySQL 变更列时需要显式保留原有默认值。 + $table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly']) + ->default('once') + ->change(); + }); + } + + /** + * 回滚 holiday_events 的年度调度字段。 + */ + public function down(): void + { + Schema::table('holiday_events', function (Blueprint $table) { + $table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron']) + ->default('once') + ->change(); + + $table->dropColumn([ + 'schedule_month', + 'schedule_day', + 'schedule_time', + 'duration_days', + 'daily_occurrences', + 'occurrence_interval_minutes', + ]); + }); + } +}; diff --git a/database/migrations/2026_04_21_173714_alter_holiday_claims_for_runs.php b/database/migrations/2026_04_21_173714_alter_holiday_claims_for_runs.php new file mode 100644 index 0000000..ce29ed2 --- /dev/null +++ b/database/migrations/2026_04_21_173714_alter_holiday_claims_for_runs.php @@ -0,0 +1,119 @@ +unsignedBigInteger('run_id')->nullable()->after('event_id')->comment('关联发放批次 ID'); + $table->index('event_id'); + }); + + Schema::table('holiday_claims', function (Blueprint $table) { + $table->dropUnique('uq_holiday_event_user'); + $table->dateTime('claimed_at')->nullable()->change(); + $table->foreign('run_id')->references('id')->on('holiday_event_runs')->cascadeOnDelete(); + $table->unique(['run_id', 'user_id'], 'uq_holiday_run_user'); + }); + + $now = CarbonImmutable::now(); + + $events = DB::table('holiday_events') + ->whereExists(function ($query) { + $query->selectRaw('1') + ->from('holiday_claims') + ->whereColumn('holiday_claims.event_id', 'holiday_events.id'); + }) + ->orderBy('id') + ->get(); + + foreach ($events as $event) { + $audienceCount = (int) DB::table('holiday_claims')->where('event_id', $event->id)->count(); + $scheduledFor = $event->triggered_at ?? $event->send_at ?? $now->toDateTimeString(); + $expiresAt = $event->expires_at; + + $runStatus = $expiresAt !== null && CarbonImmutable::parse($expiresAt)->isPast() + ? 'expired' + : 'active'; + + $runId = DB::table('holiday_event_runs')->insertGetId([ + 'holiday_event_id' => $event->id, + 'event_name' => $event->name, + 'event_description' => $event->description, + 'total_amount' => $event->total_amount, + 'max_claimants' => $event->max_claimants, + 'distribute_type' => $event->distribute_type, + 'min_amount' => $event->min_amount, + 'max_amount' => $event->max_amount, + 'fixed_amount' => $event->fixed_amount, + 'target_type' => $event->target_type, + 'target_value' => $event->target_value, + 'repeat_type' => $event->repeat_type, + 'scheduled_for' => $scheduledFor, + 'triggered_at' => $event->triggered_at ?? $scheduledFor, + 'expires_at' => $expiresAt, + 'status' => $runStatus, + 'audience_count' => $audienceCount, + 'claimed_count' => 0, + 'claimed_amount' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // 旧结构下表里剩余记录全部表示“尚未领取”,统一迁移为未领取状态。 + DB::table('holiday_claims') + ->where('event_id', $event->id) + ->update([ + 'run_id' => $runId, + 'claimed_at' => null, + ]); + + if (in_array($event->repeat_type, ['daily', 'weekly', 'monthly'], true) && $event->send_at !== null) { + $currentSendAt = CarbonImmutable::parse($event->send_at); + $nextSendAt = match ($event->repeat_type) { + 'daily' => $currentSendAt->addDay(), + 'weekly' => $currentSendAt->addWeek(), + 'monthly' => $currentSendAt->addMonth(), + default => $currentSendAt, + }; + + DB::table('holiday_events') + ->where('id', $event->id) + ->update([ + 'send_at' => $nextSendAt->toDateTimeString(), + 'status' => 'pending', + ]); + } + } + } + + /** + * 回滚 holiday_claims 的批次化关联。 + */ + public function down(): void + { + Schema::table('holiday_claims', function (Blueprint $table) { + $table->dropUnique('uq_holiday_run_user'); + $table->dropForeign(['run_id']); + $table->dateTime('claimed_at')->nullable(false)->change(); + $table->dropColumn('run_id'); + $table->dropIndex(['event_id']); + $table->unique(['event_id', 'user_id'], 'uq_holiday_event_user'); + }); + } +}; diff --git a/resources/js/chat.js b/resources/js/chat.js index 78c2649..d59c657 100644 --- a/resources/js/chat.js +++ b/resources/js/chat.js @@ -3,6 +3,176 @@ import "./bootstrap"; // 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。 // 通过 Presence Channel 实现聊天室的核心监听。 +function isHolidayObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function firstHolidayDefined(...values) { + for (const value of values) { + if (value !== undefined && value !== null && value !== "") { + return value; + } + } + + return null; +} + +function pickHolidayObject(...values) { + for (const value of values) { + if (isHolidayObject(value)) { + return value; + } + } + + return {}; +} + +function mergeHolidayObjects(...values) { + return Object.assign({}, ...values.filter(isHolidayObject)); +} + +function toHolidayNumber(value, fallback = null) { + if (value === undefined || value === null || value === "") { + return fallback; + } + + const parsedValue = Number(value); + + return Number.isFinite(parsedValue) ? parsedValue : fallback; +} + +export function normalizeHolidayBroadcastEvent(payload = {}) { + const runPayload = pickHolidayObject(payload.run, payload.holiday_run); + const snapshotPayload = pickHolidayObject( + payload.snapshot, + payload.run_snapshot, + payload.batch_snapshot, + runPayload.snapshot, + runPayload.batch_snapshot, + ); + const eventPayload = pickHolidayObject( + payload.event, + payload.holiday_event, + runPayload.event, + runPayload.template, + snapshotPayload.event, + snapshotPayload.template, + ); + const mergedPayload = mergeHolidayObjects( + eventPayload, + payload, + runPayload, + snapshotPayload, + ); + const fixedAmount = toHolidayNumber( + firstHolidayDefined( + snapshotPayload.fixed_amount, + runPayload.fixed_amount, + payload.fixed_amount, + ), + ); + + return { + ...payload, + ...mergedPayload, + run_id: firstHolidayDefined( + payload.run_id, + runPayload.run_id, + runPayload.id, + snapshotPayload.run_id, + ), + event_id: firstHolidayDefined( + payload.event_id, + runPayload.event_id, + snapshotPayload.event_id, + eventPayload.id, + ), + name: firstHolidayDefined( + snapshotPayload.name, + runPayload.name, + payload.name, + eventPayload.name, + "节日福利", + ), + description: firstHolidayDefined( + snapshotPayload.description, + runPayload.description, + payload.description, + eventPayload.description, + "", + ), + total_amount: + toHolidayNumber( + firstHolidayDefined( + snapshotPayload.total_amount, + runPayload.total_amount, + payload.total_amount, + ), + 0, + ) ?? 0, + max_claimants: + toHolidayNumber( + firstHolidayDefined( + snapshotPayload.max_claimants, + runPayload.max_claimants, + payload.max_claimants, + ), + 0, + ) ?? 0, + distribute_type: firstHolidayDefined( + snapshotPayload.distribute_type, + runPayload.distribute_type, + payload.distribute_type, + fixedAmount !== null ? "fixed" : "random", + ), + fixed_amount: fixedAmount, + scheduled_for: firstHolidayDefined( + snapshotPayload.scheduled_for, + runPayload.scheduled_for, + payload.scheduled_for, + snapshotPayload.send_at, + runPayload.send_at, + payload.send_at, + ), + expires_at: firstHolidayDefined( + snapshotPayload.expires_at, + runPayload.expires_at, + payload.expires_at, + snapshotPayload.claim_deadline_at, + runPayload.claim_deadline_at, + payload.claim_deadline_at, + ), + repeat_type: firstHolidayDefined( + snapshotPayload.repeat_type, + runPayload.repeat_type, + payload.repeat_type, + eventPayload.repeat_type, + ), + round_no: firstHolidayDefined( + snapshotPayload.round_no, + runPayload.round_no, + payload.round_no, + snapshotPayload.sequence, + runPayload.sequence, + payload.sequence, + snapshotPayload.issue_no, + runPayload.issue_no, + payload.issue_no, + ), + round_label: firstHolidayDefined( + snapshotPayload.round_label, + runPayload.round_label, + payload.round_label, + snapshotPayload.batch_label, + runPayload.batch_label, + payload.batch_label, + ), + snapshot: mergeHolidayObjects(eventPayload, runPayload, snapshotPayload), + }; +} + +window.normalizeHolidayBroadcastEvent = normalizeHolidayBroadcastEvent; + export function initChat(roomId) { if (!roomId) { console.error("未提供 roomId,无法初始化 WebSocket 连接。"); @@ -113,9 +283,10 @@ export function initChat(roomId) { }) // ─── 节日福利:系统定时发放 ──────────────────────────────── .listen(".holiday.started", (e) => { - console.log("节日福利开始:", e); + const holidayPayload = normalizeHolidayBroadcastEvent(e); + console.log("节日福利批次开始:", holidayPayload); window.dispatchEvent( - new CustomEvent("chat:holiday.started", { detail: e }), + new CustomEvent("chat:holiday.started", { detail: holidayPayload }), ); }) // ─── 百家乐:开局 & 结算 ────────────────────────────────── diff --git a/resources/views/admin/holiday-events/create.blade.php b/resources/views/admin/holiday-events/create.blade.php index 6496d22..682a2d0 100644 --- a/resources/views/admin/holiday-events/create.blade.php +++ b/resources/views/admin/holiday-events/create.blade.php @@ -3,189 +3,10 @@ @section('title', '创建节日福利活动') @section('content') -
-
-
- ← 返回列表 -

🎊 创建节日福利活动

-
- - @if ($errors->any()) -
-
    - @foreach ($errors->all() as $error) -
  • {{ $error }}
  • - @endforeach -
-
- @endif - -
- @csrf - - {{-- 基础信息 --}} -
-

📋 基础信息

-
-
- - -
-
- - -
-
-
- - {{-- 奖励配置 --}} -
-

💰 奖励配置

-
-
- - -
-
- - -
-
- -
- - -
-
- - {{-- 随机模式配置 --}} -
-
-
- - -
-
- - -
-
-
- - {{-- 定额模式配置 --}} -
-
- - -

💡 总发放 = 固定金额 × 在线人数(受最大领取人数限制)

-
-
-
-
- - {{-- 时间配置 --}} -
-

⏰ 时间配置

-
-
- - -
-
- - -
-
- - -
-
-
- - {{-- 目标用户 --}} -
-

🎯 目标用户

-
-
- - -
-
- - -
-
-
- - {{-- 提交 --}} -
- - - 取消 - -
-
-
-
- - + @include('admin.holiday-events.partials.form', [ + 'action' => route('admin.holiday-events.store'), + 'pageTitle' => '🎊 创建节日福利活动', + 'pageDescription' => '支持一次性、周期性与 yearly 年度节日调度配置。', + 'submitLabel' => '🎊 创建活动', + ]) @endsection diff --git a/resources/views/admin/holiday-events/edit.blade.php b/resources/views/admin/holiday-events/edit.blade.php new file mode 100644 index 0000000..ba3b594 --- /dev/null +++ b/resources/views/admin/holiday-events/edit.blade.php @@ -0,0 +1,14 @@ +@extends('admin.layouts.app') + +@section('title', '编辑节日福利活动') + +@section('content') + @include('admin.holiday-events.partials.form', [ + 'event' => $event, + 'action' => route('admin.holiday-events.update', $event), + 'method' => 'PUT', + 'pageTitle' => '✏️ 编辑节日福利活动', + 'pageDescription' => '可继续维护旧规则,也可切换到 yearly 年度节日高级调度。', + 'submitLabel' => '💾 保存修改', + ]) +@endsection diff --git a/resources/views/admin/holiday-events/index.blade.php b/resources/views/admin/holiday-events/index.blade.php index b29f5ef..0d0a3ee 100644 --- a/resources/views/admin/holiday-events/index.blade.php +++ b/resources/views/admin/holiday-events/index.blade.php @@ -8,7 +8,7 @@

🎊 节日福利管理

-

配置定时发放的节日金币福利,系统自动触发广播并分配红包。

+

配置一次性、周期性与年度节日福利,系统自动触发广播并分配红包。

@@ -62,6 +62,14 @@ {{ $event->send_at->format('m-d H:i') }} + @if ($event->repeat_type === 'yearly') +
+ {{ data_get($event, 'schedule_month', $event->send_at?->format('n')) }}月{{ data_get($event, 'schedule_day', $event->send_at?->format('j')) }}日 + · {{ data_get($event, 'duration_days', 1) }}天 + · 每天{{ data_get($event, 'daily_occurrences', 1) }}次 + · 间隔{{ data_get($event, 'occurrence_interval_minutes', 60) }}分钟 +
+ @endif @php @@ -70,6 +78,7 @@ 'daily' => '每天', 'weekly' => '每周', 'monthly' => '每月', + 'yearly' => '每年', 'cron' => 'CRON', ]; @endphp diff --git a/resources/views/admin/holiday-events/partials/form.blade.php b/resources/views/admin/holiday-events/partials/form.blade.php new file mode 100644 index 0000000..55175e2 --- /dev/null +++ b/resources/views/admin/holiday-events/partials/form.blade.php @@ -0,0 +1,327 @@ +@php + $holidayEvent = $event ?? null; + $isEdit = (bool) $holidayEvent; + + $inputClass = 'w-full rounded-lg border border-gray-300 p-2.5 text-sm text-gray-700 focus:border-amber-400 focus:ring-amber-400'; + $panelClass = 'rounded-xl border border-gray-100 bg-white p-6 shadow-sm'; + + $fieldValue = static fn(string $key, mixed $default = null): mixed => old($key, data_get($holidayEvent, $key, $default)); + + $sendAtValue = old('send_at'); + if ($sendAtValue === null && $holidayEvent?->send_at) { + $sendAtValue = $holidayEvent->send_at->format('Y-m-d\TH:i'); + } + + $repeatType = (string) $fieldValue('repeat_type', 'once'); + $distributeType = (string) $fieldValue('distribute_type', 'random'); + $targetType = (string) $fieldValue('target_type', 'all'); + $targetValue = old('target_value', data_get($holidayEvent, 'target_value', $targetType === 'level' ? 1 : null)); + + $scheduleMonth = old('schedule_month', data_get($holidayEvent, 'schedule_month', $holidayEvent?->send_at?->format('n') ?? now()->format('n'))); + $scheduleDay = old('schedule_day', data_get($holidayEvent, 'schedule_day', $holidayEvent?->send_at?->format('j') ?? now()->format('j'))); + $scheduleTime = old('schedule_time', data_get($holidayEvent, 'schedule_time', $holidayEvent?->send_at?->format('H:i') ?? '20:00')); + $durationDays = old('duration_days', data_get($holidayEvent, 'duration_days', 1)); + $dailyOccurrences = old('daily_occurrences', data_get($holidayEvent, 'daily_occurrences', 1)); + $occurrenceIntervalMinutes = old('occurrence_interval_minutes', data_get($holidayEvent, 'occurrence_interval_minutes', 60)); +@endphp + +
+
+
+
+ ← 返回列表 +
+

{{ $pageTitle }}

+

{{ $pageDescription }}

+
+
+ @if ($isEdit && data_get($holidayEvent, 'status')) + + 当前状态:{{ data_get($holidayEvent, 'status') }} + + @endif +
+ + @if ($errors->any()) +
+
提交失败,请检查以下字段:
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf + @isset($method) + @method($method) + @endisset + +
+

📋 基础信息

+
+
+ + +
+
+ + +
+
+
+ +
+

💰 奖励配置

+
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +

总发放金额 = 固定金额 × 领取人数(仍受领取人数上限控制)。

+
+
+
+
+ +
+

⏰ 时间配置

+
+
+ + +

+ `once/daily/weekly/monthly` 使用该时间直接调度;`yearly` 则把它作为首次触发与兼容基准值。 +

+
+
+ + +
+
+ + +
+
+ 活动只会在指定 `send_at` 触发一次。 +
+
+ 以 `send_at` 的时分为基准,每天自动重复一次。 +
+
+ 以 `send_at` 的星期与时间为基准,每周自动重复。 +
+
+ 以 `send_at` 的日期与时间为基准,每月自动重复。 +
+
+ 每年同一节日重复,可配置连续多天与每天多次发送。 +
+
+ 兼容旧版 CRON 配置活动,适合保留现有高级规则。 +
+
+
+ +
+
+
+
+

🎯 年度节日高级调度

+

用于 yearly:指定每年节日日期、连续天数以及每天的多次发送频率。

+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+

🎯 目标用户

+
+
+ + +
+
+ + +
+
+
+ 当前仍沿用后端 `target_type=vip` 逻辑,若主线后端升级为指定 VIP 档位,可继续复用 `target_value`。 +
+
+
+
+ +
+ + + 取消 + +
+
+
+
+ + diff --git a/resources/views/chat/partials/holiday-modal.blade.php b/resources/views/chat/partials/holiday-modal.blade.php index 989348e..2f493b1 100644 --- a/resources/views/chat/partials/holiday-modal.blade.php +++ b/resources/views/chat/partials/holiday-modal.blade.php @@ -1,11 +1,12 @@ {{-- 文件功能:节日福利弹窗组件 - 后台配置的节日活动触发时,通过 WebSocket 广播到达前端, + 后台配置的节日福利批次触发时,通过 WebSocket 广播到达前端, 弹出全屏福利领取弹窗,用户点击领取后金币自动入账。 WebSocket 监听:chat:holiday.started - 领取接口:POST /holiday/{event}/claim + 领取接口:POST /holiday/runs/{run}/claim + 状态接口:GET /holiday/runs/{run}/status --}} {{-- ─── 节日福利领取弹窗 ─── --}} @@ -23,6 +24,9 @@
{{-- 主图标动效 --}}
🎊
+
@@ -33,34 +37,43 @@ {{-- 奖池信息 --}}
-
💰 本次节日总奖池
+
💰 本轮节日奖池
全体在线用户均可领取
+
+ + 本轮按随机金额发放 +
{{-- 有效期 --}}
+
+ 发放时间 +
领取有效期 ,过期作废 +
{{-- 领取按钮 --}}
-
@@ -69,8 +82,8 @@
-
🎉 恭喜!节日福利已入账!
-
金币已自动到账,新年快乐 🥳
+
🎉 恭喜!本轮节日福利已入账!
+
金币已自动到账,后续轮次开始时会继续提醒你 ✨
@@ -107,6 +120,143 @@