262 lines
8.1 KiB
PHP
262 lines
8.1 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\User;
|
||
use App\Services\ChatStateService;
|
||
use App\Services\UserCurrencyService;
|
||
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 节日活动记录
|
||
*/
|
||
public function __construct(
|
||
public readonly HolidayEvent $event,
|
||
) {}
|
||
|
||
/**
|
||
* 执行任务:触发节日福利发放。
|
||
*/
|
||
public function handle(
|
||
ChatStateService $chatState,
|
||
UserCurrencyService $currency,
|
||
): void {
|
||
$event = $this->event->fresh();
|
||
|
||
// 防止重复触发
|
||
if (! $event || $event->status !== 'pending') {
|
||
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);
|
||
|
||
if (empty($onlineIds)) {
|
||
// 无合格在线用户,直接标记完成
|
||
$event->update(['status' => 'completed']);
|
||
|
||
return;
|
||
}
|
||
|
||
// 按 max_claimants 限制人数
|
||
if ($event->max_claimants > 0 && count($onlineIds) > $event->max_claimants) {
|
||
shuffle($onlineIds);
|
||
$onlineIds = array_slice($onlineIds, 0, $event->max_claimants);
|
||
}
|
||
|
||
// 计算每人金额
|
||
$amounts = $this->distributeAmounts($event, count($onlineIds));
|
||
|
||
DB::transaction(function () use ($event, $onlineIds, $amounts, $now) {
|
||
$claims = [];
|
||
|
||
foreach ($onlineIds as $i => $userId) {
|
||
$claims[] = [
|
||
'event_id' => $event->id,
|
||
'user_id' => $userId,
|
||
'amount' => $amounts[$i] ?? 0,
|
||
'claimed_at' => $now,
|
||
];
|
||
}
|
||
|
||
// 批量插入领取记录
|
||
HolidayClaim::insert($claims);
|
||
});
|
||
|
||
// 广播全房间 WebSocket 事件
|
||
broadcast(new HolidayEventStarted($event->refresh()));
|
||
|
||
// 向聊天室追加系统消息(写入 Redis + 落库)
|
||
$this->pushSystemMessage($event, count($onlineIds), $chatState);
|
||
|
||
// 处理重复活动(计算下次触发时间)
|
||
$this->scheduleNextRepeat($event);
|
||
}
|
||
|
||
/**
|
||
* 获取满足条件的在线用户 ID 列表。
|
||
*
|
||
* @return array<int>
|
||
*/
|
||
private function getEligibleOnlineUsers(HolidayEvent $event, ChatStateService $chatState): array
|
||
{
|
||
try {
|
||
$key = 'room:1:users';
|
||
$users = Redis::hgetall($key);
|
||
if (empty($users)) {
|
||
return [];
|
||
}
|
||
|
||
$usernames = array_keys($users);
|
||
|
||
// 根据 user_id 从 Redis value 或数据库查出 ID
|
||
$ids = [];
|
||
$fallbacks = [];
|
||
|
||
foreach ($users as $username => $jsonInfo) {
|
||
$info = json_decode($jsonInfo, true);
|
||
if (isset($info['user_id'])) {
|
||
$ids[] = (int) $info['user_id'];
|
||
} else {
|
||
$fallbacks[] = $username;
|
||
}
|
||
}
|
||
|
||
if (! empty($fallbacks)) {
|
||
$dbIds = User::whereIn('username', $fallbacks)->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||
$ids = array_merge($ids, $dbIds);
|
||
}
|
||
|
||
$ids = array_values(array_unique($ids));
|
||
|
||
// 根据 target_type 过滤
|
||
return match ($event->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(),
|
||
default => $ids,
|
||
};
|
||
} catch (\Throwable) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 按分配方式计算每人金额数组。
|
||
*
|
||
* @return array<int>
|
||
*/
|
||
private function distributeAmounts(HolidayEvent $event, int $count): array
|
||
{
|
||
if ($count <= 0) {
|
||
return [];
|
||
}
|
||
|
||
if ($event->distribute_type === 'fixed') {
|
||
// 定额模式:每人相同金额
|
||
$amount = $event->fixed_amount ?? (int) floor($event->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);
|
||
|
||
$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(HolidayEvent $event, int $claimCount, ChatStateService $chatState): void
|
||
{
|
||
$typeLabel = $event->distribute_type === 'fixed' ? "每人固定 {$event->fixed_amount} 金币" : '随机分配';
|
||
$content = "🎊 【{$event->name}】节日福利开始啦!总奖池 🪙".number_format($event->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);
|
||
}
|
||
|
||
/**
|
||
* 处理重复活动:计算下次触发时间并重置状态。
|
||
*/
|
||
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']);
|
||
}
|
||
}
|
||
}
|