升级节日福利年度调度与批次领取
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利活动创建请求
|
||||
*
|
||||
* 负责校验后台创建节日福利模板时提交的奖励、调度与目标用户字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\HolidayEventScheduleRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* 类功能:校验创建节日福利模板的表单数据。
|
||||
*/
|
||||
class StoreHolidayEventRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许提交创建请求。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理布尔字段,避免浏览器复选框值造成类型偏差。
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('enabled')) {
|
||||
$this->merge([
|
||||
'enabled' => $this->boolean('enabled'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节日福利模板的字段校验规则。
|
||||
*
|
||||
* @return array<string, ValidationRule|array<int, ValidationRule|string>|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<string, string>
|
||||
*/
|
||||
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' => '指定等级以上模式下必须填写最低等级',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利活动更新请求
|
||||
*
|
||||
* 复用创建请求的字段校验规则,用于后台编辑节日福利模板。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
/**
|
||||
* 类功能:校验更新节日福利模板的表单数据。
|
||||
*/
|
||||
class UpdateHolidayEventRequest extends StoreHolidayEventRequest {}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联领取用户。
|
||||
*/
|
||||
|
||||
+24
-12
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利发放批次模型
|
||||
*
|
||||
* 记录节日福利模板的每一次实际发放批次,
|
||||
* 承担领取有效期、批次统计与历史追踪职责。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* 类功能:封装节日福利单次发放批次的时间、状态和领取统计。
|
||||
*/
|
||||
class HolidayEventRun extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\HolidayEventRunFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利年度调度校验规则
|
||||
*
|
||||
* 负责校验 yearly 模式下的月/日/时间、多天与多轮次组合是否合法。
|
||||
*/
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\DataAwareRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* 类功能:校验节日福利年度调度配置的组合合法性。
|
||||
*/
|
||||
class HolidayEventScheduleRule implements DataAwareRule, ValidationRule
|
||||
{
|
||||
/**
|
||||
* 当前请求的全部字段。
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* 注入待校验的完整数据集。
|
||||
*
|
||||
* @param array<string, mixed> $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('同一天多轮发送的最后一轮不能跨到次日,请缩短间隔或减少次数。');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利调度计算服务
|
||||
*
|
||||
* 统一负责节日福利模板的首次触发时间与下一次触发时间推导,
|
||||
* 支持普通重复模式与年度节日高级调度。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
|
||||
/**
|
||||
* 类功能:计算节日福利模板的下一次触发时间。
|
||||
*/
|
||||
class HolidayEventScheduleService
|
||||
{
|
||||
/**
|
||||
* 根据表单配置计算模板的下一次触发时间。
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $data
|
||||
* @return array<int, CarbonImmutable>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user