Files
chatroom/app/Jobs/TriggerHolidayEventJob.php
T
2026-05-09 11:14:55 +08:00

308 lines
11 KiB
PHP

<?php
/**
* 文件功能:节日福利触发队列任务
*
* 由 Laravel 调度器每分钟检查并触发到期的节日活动:
* 计算在线用户 → 分配金额 → 写入领取记录 → 广播 WebSocket 事件。
* 通过 Horizon 队列异步执行,不阻塞 HTTP 请求。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\HolidayEventStarted;
use App\Events\MessageSent;
use App\Models\HolidayClaim;
use App\Models\HolidayEvent;
use App\Models\HolidayEventRun;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\HolidayEventScheduleService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
/**
* 类功能:按模板配置生成节日福利发放批次并广播给前端。
*/
class TriggerHolidayEventJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数(避免因为临时故障无限重试)。
*/
public int $tries = 3;
/**
* @param HolidayEvent $event 节日活动模板
* @param bool $manual 是否为管理员手动立即触发
*/
public function __construct(
public readonly HolidayEvent $event,
public readonly bool $manual = false,
) {}
/**
* 执行任务:触发节日福利发放。
*/
public function handle(
ChatStateService $chatState,
HolidayEventScheduleService $scheduleService,
): void {
$run = $this->prepareRun($scheduleService);
if (! $run) {
return;
}
// 获取在线用户(满足目标用户条件),每一轮都按触发当时的在线状态重新计算。
$onlineIds = $this->getEligibleOnlineUsers($run);
if (empty($onlineIds)) {
$run->update(['status' => 'completed', 'audience_count' => 0]);
return;
}
// 按本批次快照中的 max_claimants 限制人数。
if ($run->max_claimants > 0 && count($onlineIds) > $run->max_claimants) {
shuffle($onlineIds);
$onlineIds = array_slice($onlineIds, 0, $run->max_claimants);
}
// 计算每人的待领取金额。
$amounts = $this->distributeAmounts($run, count($onlineIds));
DB::transaction(function () use ($run, $onlineIds, $amounts): void {
$claims = [];
foreach ($onlineIds as $i => $userId) {
$claims[] = [
'event_id' => $run->holiday_event_id,
'run_id' => $run->id,
'user_id' => $userId,
'amount' => $amounts[$i] ?? 0,
'claimed_at' => null,
];
}
// 一次性生成本轮全部待领取记录,claimed_at 默认为 null。
HolidayClaim::insert($claims);
$run->update(['audience_count' => count($claims)]);
});
// 广播本轮发放批次,前端将基于 run_id 领取。
broadcast(new HolidayEventStarted($run->fresh()));
// 向聊天室追加系统公告,提醒用户点击弹窗领取。
$this->pushSystemMessage($run, count($onlineIds), $chatState);
}
/**
* 生成批次并推进模板到下一次触发时间。
*/
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;
$expiresAt = $this->manual
? $now->copy()->addMinutes($event->expire_minutes)
: $scheduledFor?->copy()->addMinutes($event->expire_minutes);
if (! $this->manual) {
// 定时触发只允许处理真正到期且仍处于 pending 的模板。
if ($event->status !== 'pending' || $scheduledFor === null || $scheduledFor->isFuture()) {
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' => $expiresAt,
'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' => $expiresAt,
'status' => 'active',
'audience_count' => 0,
'claimed_count' => 0,
'claimed_amount' => 0,
]);
});
return $run;
}
/**
* 获取满足当前批次条件的在线用户 ID 列表。
*
* @return array<int>
*/
private function getEligibleOnlineUsers(HolidayEventRun $run): array
{
try {
$users = Redis::hgetall('room:1:users');
if (empty($users)) {
return [];
}
$ids = [];
$fallbackUsernames = [];
foreach ($users as $username => $jsonInfo) {
$info = json_decode($jsonInfo, true);
if (isset($info['user_id'])) {
$ids[] = (int) $info['user_id'];
continue;
}
$fallbackUsernames[] = $username;
}
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));
// 目标用户范围以当前批次快照为准,避免模板后续编辑影响本轮名单。
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) ($run->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
default => $ids,
};
} catch (\Throwable) {
return [];
}
}
/**
* 按分配方式计算每人金额数组。
*
* @return array<int>
*/
private function distributeAmounts(HolidayEventRun $run, int $count): array
{
if ($count <= 0) {
return [];
}
if ($run->distribute_type === 'fixed') {
// 定额模式:每人固定一个金额,优先使用模板快照中的 fixed_amount。
$amount = $run->fixed_amount ?? (int) floor($run->total_amount / $count);
return array_fill(0, $count, $amount);
}
// 随机模式沿用二倍均值算法,保证总金额恰好发完。
$total = $run->total_amount;
$min = max(1, $run->min_amount ?? 1);
$max = $run->max_amount ?? (int) ceil($total * 2 / $count);
$amounts = [];
$remaining = $total;
for ($i = 0; $i < $count - 1; $i++) {
$remainingPeople = $count - $i;
$avgDouble = (int) floor($remaining * 2 / $remainingPeople);
$cap = max($min, min($max, $avgDouble - 1, $remaining - ($remainingPeople - 1) * $min));
$amounts[] = random_int($min, max($min, $cap));
$remaining -= end($amounts);
}
$amounts[] = max($min, $remaining);
shuffle($amounts);
return $amounts;
}
/**
* 向聊天室推送系统公告消息并写入 Redis + 落库。
*/
private function pushSystemMessage(HolidayEventRun $run, int $claimCount, ChatStateService $chatState): void
{
$typeLabel = $run->distribute_type === 'fixed' ? "每人固定 {$run->fixed_amount} 金币" : '随机分配';
$content = "🎊 【{$run->event_name}】节日福利开始啦!总奖池 💰".number_format($run->total_amount)
." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#f59e0b',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}