升级节日福利年度调度与批次领取
This commit is contained in:
@@ -18,14 +18,18 @@ 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\UserCurrencyService;
|
||||
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;
|
||||
@@ -36,10 +40,12 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
public int $tries = 3;
|
||||
|
||||
/**
|
||||
* @param HolidayEvent $event 节日活动记录
|
||||
* @param HolidayEvent $event 节日活动模板
|
||||
* @param bool $manual 是否为管理员手动立即触发
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly HolidayEvent $event,
|
||||
public readonly bool $manual = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,119 +53,171 @@ class TriggerHolidayEventJob implements ShouldQueue
|
||||
*/
|
||||
public function handle(
|
||||
ChatStateService $chatState,
|
||||
UserCurrencyService $currency,
|
||||
HolidayEventScheduleService $scheduleService,
|
||||
): void {
|
||||
$event = $this->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<int>
|
||||
*/
|
||||
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<int>
|
||||
*/
|
||||
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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user