Files
chatroom/app/Jobs/TriggerHolidayEventJob.php

262 lines
8.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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']);
}
}
}