升级节日福利年度调度与批次领取
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利发放批次测试工厂
|
||||
*
|
||||
* 用于测试中快速创建已触发的节日福利批次与历史快照。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<HolidayEventRun>
|
||||
*/
|
||||
class HolidayEventRunFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认批次测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$event = HolidayEvent::query()->create([
|
||||
'name' => '节日福利测试模板',
|
||||
'description' => '用于测试的节日福利模板',
|
||||
'total_amount' => 6000,
|
||||
'max_claimants' => 10,
|
||||
'distribute_type' => 'fixed',
|
||||
'min_amount' => 100,
|
||||
'max_amount' => 1000,
|
||||
'fixed_amount' => 600,
|
||||
'send_at' => now(),
|
||||
'expire_minutes' => 30,
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
'status' => 'pending',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'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' => now(),
|
||||
'triggered_at' => now(),
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
'status' => 'active',
|
||||
'audience_count' => 0,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利发放批次表迁移
|
||||
*
|
||||
* 将节日福利模板的每一次实际发放拆成独立批次,
|
||||
* 用于支持按年复用、连续多天、多轮次领取与历史追踪。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 holiday_event_runs 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('holiday_event_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('holiday_event_id')->comment('所属节日福利模板 ID');
|
||||
|
||||
// 批次快照:避免模板后续被修改后影响历史记录展示。
|
||||
$table->string('event_name', 100)->comment('活动名称快照');
|
||||
$table->text('event_description')->nullable()->comment('活动描述快照');
|
||||
$table->unsignedInteger('total_amount')->comment('总金币奖池快照');
|
||||
$table->unsignedInteger('max_claimants')->default(0)->comment('本批次最大领取人数(0=不限)');
|
||||
$table->enum('distribute_type', ['random', 'fixed'])->default('random')->comment('分配方式快照');
|
||||
$table->unsignedInteger('min_amount')->default(1)->comment('随机模式最低金额快照');
|
||||
$table->unsignedInteger('max_amount')->nullable()->comment('随机模式单人上限快照');
|
||||
$table->unsignedInteger('fixed_amount')->nullable()->comment('定额模式单人金额快照');
|
||||
$table->enum('target_type', ['all', 'vip', 'level'])->default('all')->comment('目标用户类型快照');
|
||||
$table->string('target_value', 50)->nullable()->comment('目标用户条件快照');
|
||||
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])->default('once')->comment('模板重复方式快照');
|
||||
|
||||
// 批次状态与时间。
|
||||
$table->dateTime('scheduled_for')->comment('本批次原定触发时间');
|
||||
$table->dateTime('triggered_at')->comment('本批次实际触发时间');
|
||||
$table->dateTime('expires_at')->nullable()->comment('本批次领取截止时间');
|
||||
$table->enum('status', ['active', 'completed', 'expired', 'cancelled'])->default('active')->comment('批次状态');
|
||||
|
||||
// 统计信息。
|
||||
$table->unsignedInteger('audience_count')->default(0)->comment('本批次待领取总人数');
|
||||
$table->unsignedInteger('claimed_count')->default(0)->comment('本批次已领取人数');
|
||||
$table->unsignedInteger('claimed_amount')->default(0)->comment('本批次已领取金币总额');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('holiday_event_id')->references('id')->on('holiday_events')->cascadeOnDelete();
|
||||
$table->index(['status', 'expires_at']);
|
||||
$table->index(['holiday_event_id', 'scheduled_for']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 holiday_event_runs 表。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('holiday_event_runs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利模板年度调度字段迁移
|
||||
*
|
||||
* 为节日福利模板补充按年复用、连续多天与单日多轮发送所需的调度字段。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 升级 holiday_events 表结构。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('holiday_events', function (Blueprint $table) {
|
||||
$table->tinyInteger('schedule_month')->unsigned()->nullable()->after('repeat_type')->comment('年度节日触发月份');
|
||||
$table->tinyInteger('schedule_day')->unsigned()->nullable()->after('schedule_month')->comment('年度节日触发日');
|
||||
$table->string('schedule_time', 5)->nullable()->after('schedule_day')->comment('年度节日首轮开始时间(HH:ii)');
|
||||
$table->unsignedSmallInteger('duration_days')->default(1)->after('schedule_time')->comment('连续发放天数');
|
||||
$table->unsignedSmallInteger('daily_occurrences')->default(1)->after('duration_days')->comment('每天触发次数');
|
||||
$table->unsignedSmallInteger('occurrence_interval_minutes')->nullable()->after('daily_occurrences')->comment('同一天多轮发送间隔(分钟)');
|
||||
});
|
||||
|
||||
Schema::table('holiday_events', function (Blueprint $table) {
|
||||
// Laravel 12 / MySQL 变更列时需要显式保留原有默认值。
|
||||
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron', 'yearly'])
|
||||
->default('once')
|
||||
->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 holiday_events 的年度调度字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('holiday_events', function (Blueprint $table) {
|
||||
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron'])
|
||||
->default('once')
|
||||
->change();
|
||||
|
||||
$table->dropColumn([
|
||||
'schedule_month',
|
||||
'schedule_day',
|
||||
'schedule_time',
|
||||
'duration_days',
|
||||
'daily_occurrences',
|
||||
'occurrence_interval_minutes',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利领取记录批次化迁移
|
||||
*
|
||||
* 将领取记录与具体发放批次绑定,并为历史待领取数据补建 run 记录。
|
||||
*/
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 升级 holiday_claims 表并回填历史批次。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('holiday_claims', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('run_id')->nullable()->after('event_id')->comment('关联发放批次 ID');
|
||||
$table->index('event_id');
|
||||
});
|
||||
|
||||
Schema::table('holiday_claims', function (Blueprint $table) {
|
||||
$table->dropUnique('uq_holiday_event_user');
|
||||
$table->dateTime('claimed_at')->nullable()->change();
|
||||
$table->foreign('run_id')->references('id')->on('holiday_event_runs')->cascadeOnDelete();
|
||||
$table->unique(['run_id', 'user_id'], 'uq_holiday_run_user');
|
||||
});
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
$events = DB::table('holiday_events')
|
||||
->whereExists(function ($query) {
|
||||
$query->selectRaw('1')
|
||||
->from('holiday_claims')
|
||||
->whereColumn('holiday_claims.event_id', 'holiday_events.id');
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$audienceCount = (int) DB::table('holiday_claims')->where('event_id', $event->id)->count();
|
||||
$scheduledFor = $event->triggered_at ?? $event->send_at ?? $now->toDateTimeString();
|
||||
$expiresAt = $event->expires_at;
|
||||
|
||||
$runStatus = $expiresAt !== null && CarbonImmutable::parse($expiresAt)->isPast()
|
||||
? 'expired'
|
||||
: 'active';
|
||||
|
||||
$runId = DB::table('holiday_event_runs')->insertGetId([
|
||||
'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' => $event->triggered_at ?? $scheduledFor,
|
||||
'expires_at' => $expiresAt,
|
||||
'status' => $runStatus,
|
||||
'audience_count' => $audienceCount,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// 旧结构下表里剩余记录全部表示“尚未领取”,统一迁移为未领取状态。
|
||||
DB::table('holiday_claims')
|
||||
->where('event_id', $event->id)
|
||||
->update([
|
||||
'run_id' => $runId,
|
||||
'claimed_at' => null,
|
||||
]);
|
||||
|
||||
if (in_array($event->repeat_type, ['daily', 'weekly', 'monthly'], true) && $event->send_at !== null) {
|
||||
$currentSendAt = CarbonImmutable::parse($event->send_at);
|
||||
$nextSendAt = match ($event->repeat_type) {
|
||||
'daily' => $currentSendAt->addDay(),
|
||||
'weekly' => $currentSendAt->addWeek(),
|
||||
'monthly' => $currentSendAt->addMonth(),
|
||||
default => $currentSendAt,
|
||||
};
|
||||
|
||||
DB::table('holiday_events')
|
||||
->where('id', $event->id)
|
||||
->update([
|
||||
'send_at' => $nextSendAt->toDateTimeString(),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚 holiday_claims 的批次化关联。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('holiday_claims', function (Blueprint $table) {
|
||||
$table->dropUnique('uq_holiday_run_user');
|
||||
$table->dropForeign(['run_id']);
|
||||
$table->dateTime('claimed_at')->nullable(false)->change();
|
||||
$table->dropColumn('run_id');
|
||||
$table->dropIndex(['event_id']);
|
||||
$table->unique(['event_id', 'user_id'], 'uq_holiday_event_user');
|
||||
});
|
||||
}
|
||||
};
|
||||
+173
-2
@@ -3,6 +3,176 @@ import "./bootstrap";
|
||||
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
|
||||
// 通过 Presence Channel 实现聊天室的核心监听。
|
||||
|
||||
function isHolidayObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function firstHolidayDefined(...values) {
|
||||
for (const value of values) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickHolidayObject(...values) {
|
||||
for (const value of values) {
|
||||
if (isHolidayObject(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeHolidayObjects(...values) {
|
||||
return Object.assign({}, ...values.filter(isHolidayObject));
|
||||
}
|
||||
|
||||
function toHolidayNumber(value, fallback = null) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value);
|
||||
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
}
|
||||
|
||||
export function normalizeHolidayBroadcastEvent(payload = {}) {
|
||||
const runPayload = pickHolidayObject(payload.run, payload.holiday_run);
|
||||
const snapshotPayload = pickHolidayObject(
|
||||
payload.snapshot,
|
||||
payload.run_snapshot,
|
||||
payload.batch_snapshot,
|
||||
runPayload.snapshot,
|
||||
runPayload.batch_snapshot,
|
||||
);
|
||||
const eventPayload = pickHolidayObject(
|
||||
payload.event,
|
||||
payload.holiday_event,
|
||||
runPayload.event,
|
||||
runPayload.template,
|
||||
snapshotPayload.event,
|
||||
snapshotPayload.template,
|
||||
);
|
||||
const mergedPayload = mergeHolidayObjects(
|
||||
eventPayload,
|
||||
payload,
|
||||
runPayload,
|
||||
snapshotPayload,
|
||||
);
|
||||
const fixedAmount = toHolidayNumber(
|
||||
firstHolidayDefined(
|
||||
snapshotPayload.fixed_amount,
|
||||
runPayload.fixed_amount,
|
||||
payload.fixed_amount,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
...mergedPayload,
|
||||
run_id: firstHolidayDefined(
|
||||
payload.run_id,
|
||||
runPayload.run_id,
|
||||
runPayload.id,
|
||||
snapshotPayload.run_id,
|
||||
),
|
||||
event_id: firstHolidayDefined(
|
||||
payload.event_id,
|
||||
runPayload.event_id,
|
||||
snapshotPayload.event_id,
|
||||
eventPayload.id,
|
||||
),
|
||||
name: firstHolidayDefined(
|
||||
snapshotPayload.name,
|
||||
runPayload.name,
|
||||
payload.name,
|
||||
eventPayload.name,
|
||||
"节日福利",
|
||||
),
|
||||
description: firstHolidayDefined(
|
||||
snapshotPayload.description,
|
||||
runPayload.description,
|
||||
payload.description,
|
||||
eventPayload.description,
|
||||
"",
|
||||
),
|
||||
total_amount:
|
||||
toHolidayNumber(
|
||||
firstHolidayDefined(
|
||||
snapshotPayload.total_amount,
|
||||
runPayload.total_amount,
|
||||
payload.total_amount,
|
||||
),
|
||||
0,
|
||||
) ?? 0,
|
||||
max_claimants:
|
||||
toHolidayNumber(
|
||||
firstHolidayDefined(
|
||||
snapshotPayload.max_claimants,
|
||||
runPayload.max_claimants,
|
||||
payload.max_claimants,
|
||||
),
|
||||
0,
|
||||
) ?? 0,
|
||||
distribute_type: firstHolidayDefined(
|
||||
snapshotPayload.distribute_type,
|
||||
runPayload.distribute_type,
|
||||
payload.distribute_type,
|
||||
fixedAmount !== null ? "fixed" : "random",
|
||||
),
|
||||
fixed_amount: fixedAmount,
|
||||
scheduled_for: firstHolidayDefined(
|
||||
snapshotPayload.scheduled_for,
|
||||
runPayload.scheduled_for,
|
||||
payload.scheduled_for,
|
||||
snapshotPayload.send_at,
|
||||
runPayload.send_at,
|
||||
payload.send_at,
|
||||
),
|
||||
expires_at: firstHolidayDefined(
|
||||
snapshotPayload.expires_at,
|
||||
runPayload.expires_at,
|
||||
payload.expires_at,
|
||||
snapshotPayload.claim_deadline_at,
|
||||
runPayload.claim_deadline_at,
|
||||
payload.claim_deadline_at,
|
||||
),
|
||||
repeat_type: firstHolidayDefined(
|
||||
snapshotPayload.repeat_type,
|
||||
runPayload.repeat_type,
|
||||
payload.repeat_type,
|
||||
eventPayload.repeat_type,
|
||||
),
|
||||
round_no: firstHolidayDefined(
|
||||
snapshotPayload.round_no,
|
||||
runPayload.round_no,
|
||||
payload.round_no,
|
||||
snapshotPayload.sequence,
|
||||
runPayload.sequence,
|
||||
payload.sequence,
|
||||
snapshotPayload.issue_no,
|
||||
runPayload.issue_no,
|
||||
payload.issue_no,
|
||||
),
|
||||
round_label: firstHolidayDefined(
|
||||
snapshotPayload.round_label,
|
||||
runPayload.round_label,
|
||||
payload.round_label,
|
||||
snapshotPayload.batch_label,
|
||||
runPayload.batch_label,
|
||||
payload.batch_label,
|
||||
),
|
||||
snapshot: mergeHolidayObjects(eventPayload, runPayload, snapshotPayload),
|
||||
};
|
||||
}
|
||||
|
||||
window.normalizeHolidayBroadcastEvent = normalizeHolidayBroadcastEvent;
|
||||
|
||||
export function initChat(roomId) {
|
||||
if (!roomId) {
|
||||
console.error("未提供 roomId,无法初始化 WebSocket 连接。");
|
||||
@@ -113,9 +283,10 @@ export function initChat(roomId) {
|
||||
})
|
||||
// ─── 节日福利:系统定时发放 ────────────────────────────────
|
||||
.listen(".holiday.started", (e) => {
|
||||
console.log("节日福利开始:", e);
|
||||
const holidayPayload = normalizeHolidayBroadcastEvent(e);
|
||||
console.log("节日福利批次开始:", holidayPayload);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("chat:holiday.started", { detail: e }),
|
||||
new CustomEvent("chat:holiday.started", { detail: holidayPayload }),
|
||||
);
|
||||
})
|
||||
// ─── 百家乐:开局 & 结算 ──────────────────────────────────
|
||||
|
||||
@@ -3,189 +3,10 @@
|
||||
@section('title', '创建节日福利活动')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="{{ route('admin.holiday-events.index') }}" class="text-gray-400 hover:text-gray-600">← 返回列表</a>
|
||||
<h2 class="text-lg font-bold text-gray-800">🎊 创建节日福利活动</h2>
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<ul class="list-disc list-inside text-sm text-red-700 space-y-1">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('admin.holiday-events.store') }}" method="POST" x-data="holidayForm()">
|
||||
@csrf
|
||||
|
||||
{{-- 基础信息 --}}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">📋 基础信息</h3>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">活动名称 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="text" name="name" value="{{ old('name') }}" required placeholder="例:元旦快乐🎊"
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:border-amber-400 focus:ring-amber-400">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">活动描述 <span
|
||||
class="text-gray-400 font-normal">(可选,公屏广播时显示)</span></label>
|
||||
<textarea name="description" rows="2" placeholder="例:新年快乐!感谢大家一直以来的陪伴,送上新年礼物!"
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:border-amber-400 focus:ring-amber-400">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 奖励配置 --}}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">💰 奖励配置</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">总金币奖池 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="number" name="total_amount" value="{{ old('total_amount', 100000) }}" required
|
||||
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">可领取人数上限 <span
|
||||
class="text-gray-400 font-normal">(0=不限)</span></label>
|
||||
<input type="number" name="max_claimants" value="{{ old('max_claimants', 0) }}" min="0"
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-2">分配方式 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="distribute_type" value="random" x-model="distributeType"
|
||||
{{ old('distribute_type', 'random') === 'random' ? 'checked' : '' }}>
|
||||
<span class="text-sm font-bold">🎲 随机分配</span>
|
||||
<span class="text-xs text-gray-400">(二倍均值算法,每人金额不同)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="distribute_type" value="fixed" x-model="distributeType"
|
||||
{{ old('distribute_type') === 'fixed' ? 'checked' : '' }}>
|
||||
<span class="text-sm font-bold">📏 定额发放</span>
|
||||
<span class="text-xs text-gray-400">(每人相同金额)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 随机模式配置 --}}
|
||||
<div x-show="distributeType === 'random'" class="col-span-2">
|
||||
<div class="grid grid-cols-2 gap-4 bg-purple-50 rounded-lg p-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">最低保底金额</label>
|
||||
<input type="number" name="min_amount" value="{{ old('min_amount', 100) }}"
|
||||
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">单人最高上限 <span
|
||||
class="text-gray-400 font-normal">(可选)</span></label>
|
||||
<input type="number" name="max_amount" value="{{ old('max_amount') }}" min="1"
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm"
|
||||
placeholder="不填则自动计算">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 定额模式配置 --}}
|
||||
<div x-show="distributeType === 'fixed'" class="col-span-2">
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">每人固定金额 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="number" name="fixed_amount" value="{{ old('fixed_amount', 500) }}"
|
||||
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
<p class="text-xs text-gray-400 mt-1">💡 总发放 = 固定金额 × 在线人数(受最大领取人数限制)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 时间配置 --}}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">⏰ 时间配置</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">触发时间 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input type="datetime-local" name="send_at" value="{{ old('send_at') }}" required
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">领取有效期(分钟)</label>
|
||||
<input type="number" name="expire_minutes" value="{{ old('expire_minutes', 30) }}"
|
||||
min="1" max="1440"
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">重复方式</label>
|
||||
<select name="repeat_type" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
<option value="once" {{ old('repeat_type', 'once') === 'once' ? 'selected' : '' }}>仅一次
|
||||
</option>
|
||||
<option value="daily" {{ old('repeat_type') === 'daily' ? 'selected' : '' }}>每天(相同时间)
|
||||
</option>
|
||||
<option value="weekly" {{ old('repeat_type') === 'weekly' ? 'selected' : '' }}>每周(相同时间)
|
||||
</option>
|
||||
<option value="monthly" {{ old('repeat_type') === 'monthly' ? 'selected' : '' }}>
|
||||
每月(相同日期时间)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 目标用户 --}}
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">🎯 目标用户</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">用户范围</label>
|
||||
<select name="target_type" x-model="targetType"
|
||||
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
|
||||
<option value="all">全部在线用户</option>
|
||||
<option value="vip">仅 VIP 用户</option>
|
||||
<option value="level">指定等级以上</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="targetType === 'level'">
|
||||
<label class="block text-xs font-bold text-gray-600 mb-1">最低用户等级</label>
|
||||
<input type="number" name="target_value" value="{{ old('target_value', 1) }}"
|
||||
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm"
|
||||
placeholder="例:10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 提交 --}}
|
||||
<div class="flex gap-3 pt-4 border-t">
|
||||
<button type="submit"
|
||||
class="px-8 py-2.5 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 transition shadow-sm">
|
||||
🎊 创建活动
|
||||
</button>
|
||||
<a href="{{ route('admin.holiday-events.index') }}"
|
||||
class="px-6 py-2.5 bg-gray-100 text-gray-600 rounded-lg font-bold hover:bg-gray-200 transition">
|
||||
取消
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 节日福利创建表单 Alpine 组件
|
||||
*/
|
||||
function holidayForm() {
|
||||
return {
|
||||
distributeType: '{{ old('distribute_type', 'random') }}',
|
||||
targetType: '{{ old('target_type', 'all') }}',
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@include('admin.holiday-events.partials.form', [
|
||||
'action' => route('admin.holiday-events.store'),
|
||||
'pageTitle' => '🎊 创建节日福利活动',
|
||||
'pageDescription' => '支持一次性、周期性与 yearly 年度节日调度配置。',
|
||||
'submitLabel' => '🎊 创建活动',
|
||||
])
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
@extends('admin.layouts.app')
|
||||
|
||||
@section('title', '编辑节日福利活动')
|
||||
|
||||
@section('content')
|
||||
@include('admin.holiday-events.partials.form', [
|
||||
'event' => $event,
|
||||
'action' => route('admin.holiday-events.update', $event),
|
||||
'method' => 'PUT',
|
||||
'pageTitle' => '✏️ 编辑节日福利活动',
|
||||
'pageDescription' => '可继续维护旧规则,也可切换到 yearly 年度节日高级调度。',
|
||||
'submitLabel' => '💾 保存修改',
|
||||
])
|
||||
@endsection
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800">🎊 节日福利管理</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">配置定时发放的节日金币福利,系统自动触发广播并分配红包。</p>
|
||||
<p class="text-xs text-gray-500 mt-1">配置一次性、周期性与年度节日福利,系统自动触发广播并分配红包。</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.holiday-events.create') }}"
|
||||
class="px-5 py-2 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 transition text-sm shadow-sm">
|
||||
@@ -62,6 +62,14 @@
|
||||
</td>
|
||||
<td class="p-3 text-gray-600 text-xs">
|
||||
{{ $event->send_at->format('m-d H:i') }}
|
||||
@if ($event->repeat_type === 'yearly')
|
||||
<div class="mt-1 text-[11px] text-amber-600">
|
||||
{{ data_get($event, 'schedule_month', $event->send_at?->format('n')) }}月{{ data_get($event, 'schedule_day', $event->send_at?->format('j')) }}日
|
||||
· {{ data_get($event, 'duration_days', 1) }}天
|
||||
· 每天{{ data_get($event, 'daily_occurrences', 1) }}次
|
||||
· 间隔{{ data_get($event, 'occurrence_interval_minutes', 60) }}分钟
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="p-3">
|
||||
@php
|
||||
@@ -70,6 +78,7 @@
|
||||
'daily' => '每天',
|
||||
'weekly' => '每周',
|
||||
'monthly' => '每月',
|
||||
'yearly' => '每年',
|
||||
'cron' => 'CRON',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
@php
|
||||
$holidayEvent = $event ?? null;
|
||||
$isEdit = (bool) $holidayEvent;
|
||||
|
||||
$inputClass = 'w-full rounded-lg border border-gray-300 p-2.5 text-sm text-gray-700 focus:border-amber-400 focus:ring-amber-400';
|
||||
$panelClass = 'rounded-xl border border-gray-100 bg-white p-6 shadow-sm';
|
||||
|
||||
$fieldValue = static fn(string $key, mixed $default = null): mixed => old($key, data_get($holidayEvent, $key, $default));
|
||||
|
||||
$sendAtValue = old('send_at');
|
||||
if ($sendAtValue === null && $holidayEvent?->send_at) {
|
||||
$sendAtValue = $holidayEvent->send_at->format('Y-m-d\TH:i');
|
||||
}
|
||||
|
||||
$repeatType = (string) $fieldValue('repeat_type', 'once');
|
||||
$distributeType = (string) $fieldValue('distribute_type', 'random');
|
||||
$targetType = (string) $fieldValue('target_type', 'all');
|
||||
$targetValue = old('target_value', data_get($holidayEvent, 'target_value', $targetType === 'level' ? 1 : null));
|
||||
|
||||
$scheduleMonth = old('schedule_month', data_get($holidayEvent, 'schedule_month', $holidayEvent?->send_at?->format('n') ?? now()->format('n')));
|
||||
$scheduleDay = old('schedule_day', data_get($holidayEvent, 'schedule_day', $holidayEvent?->send_at?->format('j') ?? now()->format('j')));
|
||||
$scheduleTime = old('schedule_time', data_get($holidayEvent, 'schedule_time', $holidayEvent?->send_at?->format('H:i') ?? '20:00'));
|
||||
$durationDays = old('duration_days', data_get($holidayEvent, 'duration_days', 1));
|
||||
$dailyOccurrences = old('daily_occurrences', data_get($holidayEvent, 'daily_occurrences', 1));
|
||||
$occurrenceIntervalMinutes = old('occurrence_interval_minutes', data_get($holidayEvent, 'occurrence_interval_minutes', 60));
|
||||
@endphp
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div class="{{ $panelClass }}">
|
||||
<div class="mb-6 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('admin.holiday-events.index') }}" class="text-sm text-gray-400 transition hover:text-gray-600">← 返回列表</a>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800">{{ $pageTitle }}</h2>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ $pageDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@if ($isEdit && data_get($holidayEvent, 'status'))
|
||||
<span class="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-500">
|
||||
当前状态:{{ data_get($holidayEvent, 'status') }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div class="mb-2 text-sm font-bold text-red-700">提交失败,请检查以下字段:</div>
|
||||
<ul class="list-inside list-disc space-y-1 text-sm text-red-700">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ $action }}" method="POST"
|
||||
x-data="holidayEventForm(@js([
|
||||
'repeatType' => $repeatType,
|
||||
'distributeType' => $distributeType,
|
||||
'targetType' => $targetType,
|
||||
'scheduleMonth' => (string) $scheduleMonth,
|
||||
'scheduleDay' => (string) $scheduleDay,
|
||||
'scheduleTime' => (string) $scheduleTime,
|
||||
'durationDays' => (string) $durationDays,
|
||||
'dailyOccurrences' => (string) $dailyOccurrences,
|
||||
'occurrenceIntervalMinutes' => (string) $occurrenceIntervalMinutes,
|
||||
]))"
|
||||
class="space-y-6">
|
||||
@csrf
|
||||
@isset($method)
|
||||
@method($method)
|
||||
@endisset
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">📋 基础信息</h3>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label for="name" class="mb-1 block text-xs font-bold text-gray-600">活动名称 <span class="text-red-500">*</span></label>
|
||||
<input id="name" type="text" name="name" value="{{ $fieldValue('name') }}" required
|
||||
placeholder="例:元旦快乐🎊" class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-xs font-bold text-gray-600">活动描述 <span
|
||||
class="font-normal text-gray-400">(可选,公屏广播时显示)</span></label>
|
||||
<textarea id="description" name="description" rows="3" placeholder="例:新年快乐!感谢大家一直以来的陪伴,送上新年礼物!"
|
||||
class="{{ $inputClass }}">{{ $fieldValue('description') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">💰 奖励配置</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="total_amount" class="mb-1 block text-xs font-bold text-gray-600">总金币奖池 <span class="text-red-500">*</span></label>
|
||||
<input id="total_amount" type="number" name="total_amount"
|
||||
value="{{ $fieldValue('total_amount', 100000) }}" min="1" required class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="max_claimants" class="mb-1 block text-xs font-bold text-gray-600">可领取人数上限 <span
|
||||
class="font-normal text-gray-400">(0 = 不限)</span></label>
|
||||
<input id="max_claimants" type="number" name="max_claimants"
|
||||
value="{{ $fieldValue('max_claimants', 0) }}" min="0" class="{{ $inputClass }}">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-2 block text-xs font-bold text-gray-600">分配方式 <span class="text-red-500">*</span></label>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-100 bg-purple-50 px-4 py-3 transition hover:border-purple-200">
|
||||
<input type="radio" name="distribute_type" value="random" x-model="distributeType"
|
||||
class="mt-1" />
|
||||
<span>
|
||||
<span class="block text-sm font-bold text-gray-800">🎲 随机分配</span>
|
||||
<span class="mt-1 block text-xs text-gray-500">二倍均值算法,每位用户领取金额不同。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 transition hover:border-blue-200">
|
||||
<input type="radio" name="distribute_type" value="fixed" x-model="distributeType"
|
||||
class="mt-1" />
|
||||
<span>
|
||||
<span class="block text-sm font-bold text-gray-800">📏 定额发放</span>
|
||||
<span class="mt-1 block text-xs text-gray-500">每位用户领取固定金额,更方便控制总量。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="distributeType === 'random'" x-transition.opacity class="md:col-span-2">
|
||||
<div class="grid gap-4 rounded-xl border border-purple-100 bg-purple-50 p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="min_amount" class="mb-1 block text-xs font-bold text-gray-600">最低保底金额</label>
|
||||
<input id="min_amount" type="number" name="min_amount"
|
||||
value="{{ $fieldValue('min_amount', 100) }}" min="1" class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="max_amount" class="mb-1 block text-xs font-bold text-gray-600">单人最高上限 <span
|
||||
class="font-normal text-gray-400">(可选)</span></label>
|
||||
<input id="max_amount" type="number" name="max_amount" value="{{ $fieldValue('max_amount') }}"
|
||||
min="1" placeholder="不填则由后端自动计算" class="{{ $inputClass }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="distributeType === 'fixed'" x-transition.opacity class="md:col-span-2">
|
||||
<div class="rounded-xl border border-blue-100 bg-blue-50 p-4">
|
||||
<label for="fixed_amount" class="mb-1 block text-xs font-bold text-gray-600">每人固定金额 <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input id="fixed_amount" type="number" name="fixed_amount"
|
||||
value="{{ $fieldValue('fixed_amount', 500) }}" min="1" class="{{ $inputClass }}">
|
||||
<p class="mt-2 text-xs text-gray-500">总发放金额 = 固定金额 × 领取人数(仍受领取人数上限控制)。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">⏰ 时间配置</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="send_at" class="mb-1 block text-xs font-bold text-gray-600"
|
||||
x-text="repeatType === 'once' ? '触发时间 *' : '首次触发 / 基准时间 *'"></label>
|
||||
<input id="send_at" type="datetime-local" name="send_at" value="{{ $sendAtValue }}" required
|
||||
class="{{ $inputClass }}">
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
`once/daily/weekly/monthly` 使用该时间直接调度;`yearly` 则把它作为首次触发与兼容基准值。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="expire_minutes" class="mb-1 block text-xs font-bold text-gray-600">领取有效期(分钟)</label>
|
||||
<input id="expire_minutes" type="number" name="expire_minutes"
|
||||
value="{{ $fieldValue('expire_minutes', 30) }}" min="1" max="1440"
|
||||
class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="repeat_type" class="mb-1 block text-xs font-bold text-gray-600">重复方式</label>
|
||||
<select id="repeat_type" name="repeat_type" x-model="repeatType" class="{{ $inputClass }}">
|
||||
<option value="once">仅一次</option>
|
||||
<option value="daily">每天(相同时间)</option>
|
||||
<option value="weekly">每周(相同时间)</option>
|
||||
<option value="monthly">每月(相同日期时间)</option>
|
||||
<option value="yearly">每年节日(高级调度)</option>
|
||||
<option value="cron">高级 CRON</option>
|
||||
</select>
|
||||
<div class="mt-3 grid gap-3 text-xs text-gray-500 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'once'">
|
||||
活动只会在指定 `send_at` 触发一次。
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'daily'">
|
||||
以 `send_at` 的时分为基准,每天自动重复一次。
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'weekly'">
|
||||
以 `send_at` 的星期与时间为基准,每周自动重复。
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'monthly'">
|
||||
以 `send_at` 的日期与时间为基准,每月自动重复。
|
||||
</div>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-amber-700"
|
||||
x-show="repeatType === 'yearly'">
|
||||
每年同一节日重复,可配置连续多天与每天多次发送。
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-slate-600"
|
||||
x-show="repeatType === 'cron'">
|
||||
兼容旧版 CRON 配置活动,适合保留现有高级规则。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2" x-show="repeatType === 'yearly'" x-transition.opacity>
|
||||
<div class="space-y-4 rounded-2xl border border-amber-200 bg-gradient-to-br from-amber-50 via-orange-50 to-white p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-amber-700">🎯 年度节日高级调度</h4>
|
||||
<p class="mt-1 text-xs text-amber-700/80">用于 yearly:指定每年节日日期、连续天数以及每天的多次发送频率。</p>
|
||||
</div>
|
||||
<div class="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 shadow-sm"
|
||||
x-text="yearlySummary()"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label for="schedule_month" class="mb-1 block text-xs font-bold text-gray-600">节日月份</label>
|
||||
<input id="schedule_month" type="number" name="schedule_month" min="1" max="12"
|
||||
x-model="scheduleMonth" value="{{ $scheduleMonth }}" class="{{ $inputClass }}"
|
||||
placeholder="1-12">
|
||||
</div>
|
||||
<div>
|
||||
<label for="schedule_day" class="mb-1 block text-xs font-bold text-gray-600">节日日期</label>
|
||||
<input id="schedule_day" type="number" name="schedule_day" min="1" max="31"
|
||||
x-model="scheduleDay" value="{{ $scheduleDay }}" class="{{ $inputClass }}"
|
||||
placeholder="1-31">
|
||||
</div>
|
||||
<div>
|
||||
<label for="schedule_time" class="mb-1 block text-xs font-bold text-gray-600">首个发送时刻</label>
|
||||
<input id="schedule_time" type="time" name="schedule_time" x-model="scheduleTime"
|
||||
value="{{ $scheduleTime }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="duration_days" class="mb-1 block text-xs font-bold text-gray-600">连续天数</label>
|
||||
<input id="duration_days" type="number" name="duration_days" min="1" max="31"
|
||||
x-model="durationDays" value="{{ $durationDays }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="daily_occurrences" class="mb-1 block text-xs font-bold text-gray-600">每天发送次数</label>
|
||||
<input id="daily_occurrences" type="number" name="daily_occurrences" min="1" max="24"
|
||||
x-model="dailyOccurrences" value="{{ $dailyOccurrences }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="occurrence_interval_minutes"
|
||||
class="mb-1 block text-xs font-bold text-gray-600">发送间隔(分钟)</label>
|
||||
<input id="occurrence_interval_minutes" type="number" name="occurrence_interval_minutes"
|
||||
min="1" max="1440" x-model="occurrenceIntervalMinutes"
|
||||
value="{{ $occurrenceIntervalMinutes }}" class="{{ $inputClass }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2" x-show="repeatType === 'cron'" x-transition.opacity>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<label for="cron_expr" class="mb-1 block text-xs font-bold text-gray-600">CRON 表达式</label>
|
||||
<input id="cron_expr" type="text" name="cron_expr" value="{{ $fieldValue('cron_expr') }}"
|
||||
class="{{ $inputClass }}" placeholder="例:0 9 * * 1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">🎯 目标用户</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="target_type" class="mb-1 block text-xs font-bold text-gray-600">用户范围</label>
|
||||
<select id="target_type" name="target_type" x-model="targetType" class="{{ $inputClass }}">
|
||||
<option value="all">全部在线用户</option>
|
||||
<option value="vip">仅 VIP 用户</option>
|
||||
<option value="level">指定等级以上</option>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="targetType !== 'all'" x-transition.opacity>
|
||||
<label for="target_value" class="mb-1 block text-xs font-bold text-gray-600"
|
||||
x-text="targetType === 'vip' ? 'VIP 目标值 / 备注' : '最低用户等级'"></label>
|
||||
<input id="target_value" x-bind:type="targetType === 'level' ? 'number' : 'text'" name="target_value"
|
||||
value="{{ $targetValue }}" x-bind:min="targetType === 'level' ? 1 : null"
|
||||
class="{{ $inputClass }}" :placeholder="targetType === 'vip' ? '例:gold / 3 / 高级档位' : '例:10'">
|
||||
</div>
|
||||
<div x-show="targetType === 'vip'" x-transition.opacity class="md:col-span-2">
|
||||
<div class="rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-xs text-indigo-700">
|
||||
当前仍沿用后端 `target_type=vip` 逻辑,若主线后端升级为指定 VIP 档位,可继续复用 `target_value`。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 border-t pt-4">
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-amber-500 px-8 py-2.5 font-bold text-white shadow-sm transition hover:bg-amber-600">
|
||||
{{ $submitLabel }}
|
||||
</button>
|
||||
<a href="{{ route('admin.holiday-events.index') }}"
|
||||
class="rounded-lg bg-gray-100 px-6 py-2.5 font-bold text-gray-600 transition hover:bg-gray-200">
|
||||
取消
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function holidayEventForm(config) {
|
||||
return {
|
||||
repeatType: config.repeatType ?? 'once',
|
||||
distributeType: config.distributeType ?? 'random',
|
||||
targetType: config.targetType ?? 'all',
|
||||
scheduleMonth: config.scheduleMonth ?? '1',
|
||||
scheduleDay: config.scheduleDay ?? '1',
|
||||
scheduleTime: config.scheduleTime ?? '20:00',
|
||||
durationDays: config.durationDays ?? '1',
|
||||
dailyOccurrences: config.dailyOccurrences ?? '1',
|
||||
occurrenceIntervalMinutes: config.occurrenceIntervalMinutes ?? '60',
|
||||
yearlySummary() {
|
||||
return `每年 ${this.scheduleMonth} 月 ${this.scheduleDay} 日 ${this.scheduleTime},连续 ${this.durationDays} 天,每天 ${this.dailyOccurrences} 次 / ${this.occurrenceIntervalMinutes} 分钟`;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,12 @@
|
||||
{{--
|
||||
文件功能:节日福利弹窗组件
|
||||
|
||||
后台配置的节日活动触发时,通过 WebSocket 广播到达前端,
|
||||
后台配置的节日福利批次触发时,通过 WebSocket 广播到达前端,
|
||||
弹出全屏福利领取弹窗,用户点击领取后金币自动入账。
|
||||
|
||||
WebSocket 监听:chat:holiday.started
|
||||
领取接口:POST /holiday/{event}/claim
|
||||
领取接口:POST /holiday/runs/{run}/claim
|
||||
状态接口:GET /holiday/runs/{run}/status
|
||||
--}}
|
||||
|
||||
{{-- ─── 节日福利领取弹窗 ─── --}}
|
||||
@@ -23,6 +24,9 @@
|
||||
<div style="background:linear-gradient(145deg,#92400e,#b45309,#d97706); padding:28px 24px 20px;">
|
||||
{{-- 主图标动效 --}}
|
||||
<div style="font-size:56px; margin-bottom:10px; animation:holiday-bounce 1.2s infinite;">🎊</div>
|
||||
<div x-show="roundLabel"
|
||||
style="display:none; margin-bottom:10px; color:#fde68a; font-size:11px; font-weight:bold; letter-spacing:1px;"
|
||||
x-text="roundLabel"></div>
|
||||
<div style="color:#fef3c7; font-weight:bold; font-size:20px; margin-bottom:4px;" x-text="eventName">
|
||||
</div>
|
||||
<div style="color:rgba(254,243,199,.8); font-size:13px;" x-text="eventDesc"></div>
|
||||
@@ -33,34 +37,43 @@
|
||||
|
||||
{{-- 奖池信息 --}}
|
||||
<div style="background:rgba(0,0,0,.25); border-radius:14px; padding:12px 16px; margin-bottom:16px;">
|
||||
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-bottom:4px;">💰 本次节日总奖池</div>
|
||||
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-bottom:4px;">💰 本轮节日奖池</div>
|
||||
<div style="color:#fcd34d; font-size:24px; font-weight:bold;"
|
||||
x-text="totalAmount.toLocaleString() + ' 金币'"></div>
|
||||
<div style="color:rgba(254,243,199,.5); font-size:11px; margin-top:4px;">
|
||||
<span x-show="maxClaimants > 0" x-text="'前 ' + maxClaimants + ' 名在线用户可领取'"></span>
|
||||
<span x-show="maxClaimants === 0">全体在线用户均可领取</span>
|
||||
</div>
|
||||
<div style="color:rgba(254,243,199,.5); font-size:11px; margin-top:4px;">
|
||||
<span x-show="distributeType === 'fixed' && fixedAmount !== null"
|
||||
x-text="'每人固定 ' + Number(fixedAmount).toLocaleString() + ' 金币'"></span>
|
||||
<span x-show="distributeType !== 'fixed' || fixedAmount === null">本轮按随机金额发放</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 有效期 --}}
|
||||
<div style="color:rgba(254,243,199,.55); font-size:11px; margin-bottom:14px;">
|
||||
<div x-show="scheduledForText" style="display:none; margin-bottom:4px;">
|
||||
发放时间 <strong style="color:#fcd34d;" x-text="scheduledForText"></strong>
|
||||
</div>
|
||||
领取有效期 <strong style="color:#fcd34d;" x-text="expiresIn"></strong>,过期作废
|
||||
<div x-show="statusHint" style="display:none; margin-top:6px;" x-text="statusHint"></div>
|
||||
</div>
|
||||
|
||||
{{-- 领取按钮 --}}
|
||||
<div x-show="!claimed">
|
||||
<div style="background:rgba(0,0,0,.3); border-radius:16px; padding:5px;">
|
||||
<button x-on:click="doClaim()" :disabled="claiming"
|
||||
<button x-on:click="doClaim()" :disabled="claiming || loadingStatus || !claimable"
|
||||
style="display:block; width:100%; padding:14px 0; border:none; border-radius:12px;
|
||||
font-size:16px; font-weight:bold; cursor:pointer; transition:all .15s;
|
||||
letter-spacing:1px; color:#fff;"
|
||||
:style="claiming
|
||||
:style="claiming || loadingStatus || !claimable
|
||||
?
|
||||
'background:#b45309; opacity:.65; cursor:not-allowed;' :
|
||||
'background:#d97706; box-shadow:0 2px 12px rgba(0,0,0,.4);'"
|
||||
onmouseover="if(!this.disabled) this.style.filter='brightness(1.12)'"
|
||||
onmouseout="this.style.filter=''">
|
||||
<span x-text="claiming ? '领取中…' : '🎁 立即领取福利'"></span>
|
||||
<span x-text="claimButtonText()"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,8 +82,8 @@
|
||||
<div x-show="claimed" style="display:none;">
|
||||
<div style="font-size:36px; margin-bottom:6px; color:#fcd34d; font-weight:bold;"
|
||||
x-text="'+' + claimedAmount.toLocaleString() + ' 金币'"></div>
|
||||
<div style="color:#fef3c7; font-size:13px;">🎉 恭喜!节日福利已入账!</div>
|
||||
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-top:4px;">金币已自动到账,新年快乐 🥳</div>
|
||||
<div style="color:#fef3c7; font-size:13px;">🎉 恭喜!本轮节日福利已入账!</div>
|
||||
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-top:4px;">金币已自动到账,后续轮次开始时会继续提醒你 ✨</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +120,143 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function firstHolidayDefined(...values) {
|
||||
for (const value of values) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toHolidayNumber(value, fallback = 0) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value);
|
||||
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
}
|
||||
|
||||
function parseHolidayDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(value);
|
||||
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||
}
|
||||
|
||||
function formatHolidayDate(value) {
|
||||
const parsedDate = parseHolidayDate(value);
|
||||
|
||||
if (!parsedDate) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(parsedDate);
|
||||
}
|
||||
|
||||
function formatHolidayRemaining(expiresAt) {
|
||||
if (!expiresAt) {
|
||||
return '以后端状态为准';
|
||||
}
|
||||
|
||||
const remainingMilliseconds = expiresAt.getTime() - Date.now();
|
||||
|
||||
if (remainingMilliseconds <= 0) {
|
||||
return '已过期';
|
||||
}
|
||||
|
||||
const totalMinutes = Math.ceil(remainingMilliseconds / 60000);
|
||||
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes} 分钟`;
|
||||
}
|
||||
|
||||
if (totalMinutes < 24 * 60) {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`;
|
||||
}
|
||||
|
||||
const days = Math.floor(totalMinutes / (24 * 60));
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
|
||||
|
||||
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`;
|
||||
}
|
||||
|
||||
function buildHolidayRoundLabel(detail) {
|
||||
const explicitLabel = firstHolidayDefined(detail.round_label, detail.batch_label, detail.run_label);
|
||||
|
||||
if (explicitLabel) {
|
||||
return explicitLabel;
|
||||
}
|
||||
|
||||
const roundNumber = toHolidayNumber(detail.round_no, 0);
|
||||
|
||||
if (detail.repeat_type === 'yearly') {
|
||||
return roundNumber > 0 ? `年度第 ${roundNumber} 轮福利` : '年度福利批次';
|
||||
}
|
||||
|
||||
if (roundNumber > 0) {
|
||||
return `第 ${roundNumber} 轮福利`;
|
||||
}
|
||||
|
||||
return detail.scheduled_for ? '本轮定时福利' : '当前福利批次';
|
||||
}
|
||||
|
||||
function buildHolidayDescription(detail) {
|
||||
if (detail.description) {
|
||||
return detail.description;
|
||||
}
|
||||
|
||||
const amountText = detail.distribute_type === 'fixed' && detail.fixed_amount !== null
|
||||
? `每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币`
|
||||
: '随机金额发放';
|
||||
const quotaText = detail.max_claimants > 0
|
||||
? `前 ${detail.max_claimants} 名在线用户可领取`
|
||||
: '在线用户均可领取';
|
||||
|
||||
return `本轮福利已开启,${amountText},${quotaText}。`;
|
||||
}
|
||||
|
||||
function normalizeHolidayPayload(detail) {
|
||||
if (typeof window.normalizeHolidayBroadcastEvent === 'function') {
|
||||
return window.normalizeHolidayBroadcastEvent(detail);
|
||||
}
|
||||
|
||||
return detail ?? {};
|
||||
}
|
||||
|
||||
function buildHolidaySystemMessage(detail) {
|
||||
const quotaText = detail.max_claimants > 0
|
||||
? `前 ${detail.max_claimants} 名在线用户可领取`
|
||||
: '在线用户均可领取';
|
||||
const amountText = detail.distribute_type === 'fixed' && detail.fixed_amount !== null
|
||||
? `每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币`
|
||||
: '随机金额发放';
|
||||
const scheduleText = detail.scheduled_for ? `发放时间 ${formatHolidayDate(detail.scheduled_for)}` : null;
|
||||
const roundText = detail.round_label ? ` ${detail.round_label}` : '';
|
||||
|
||||
return [
|
||||
`🎊 【${detail.name}】${roundText}开始啦!`,
|
||||
`总奖池 💰${Number(detail.total_amount).toLocaleString()} 金币`,
|
||||
amountText,
|
||||
quotaText,
|
||||
scheduleText,
|
||||
].filter(Boolean).join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 节日福利弹窗组件
|
||||
* 监听 WebSocket 事件 holiday.started,弹出领取弹窗
|
||||
@@ -114,38 +264,75 @@
|
||||
function holidayEventModal() {
|
||||
return {
|
||||
show: false,
|
||||
loadingStatus: false,
|
||||
claiming: false,
|
||||
claimable: true,
|
||||
claimed: false,
|
||||
expiresTimer: null,
|
||||
|
||||
// 活动数据
|
||||
eventId: null,
|
||||
runId: null,
|
||||
legacyEventId: null,
|
||||
eventName: '',
|
||||
eventDesc: '',
|
||||
roundLabel: '',
|
||||
totalAmount: 0,
|
||||
maxClaimants: 0,
|
||||
distributeType: 'random',
|
||||
fixedAmount: null,
|
||||
scheduledFor: null,
|
||||
scheduledForText: '',
|
||||
expiresAt: null,
|
||||
expiresIn: '',
|
||||
claimedAmount: 0,
|
||||
statusHint: '',
|
||||
|
||||
claimButtonText() {
|
||||
if (this.loadingStatus) {
|
||||
return '同步领取状态中…';
|
||||
}
|
||||
|
||||
if (this.claiming) {
|
||||
return '领取中…';
|
||||
}
|
||||
|
||||
if (!this.claimable) {
|
||||
return '当前不可领取';
|
||||
}
|
||||
|
||||
return '🎁 立即领取福利';
|
||||
},
|
||||
|
||||
/**
|
||||
* 打开弹窗并填充活动数据
|
||||
*/
|
||||
open(detail) {
|
||||
this.eventId = detail.event_id;
|
||||
this.eventName = detail.name ?? '节日福利';
|
||||
this.eventDesc = detail.description ?? '';
|
||||
this.totalAmount = detail.total_amount ?? 0;
|
||||
this.maxClaimants = detail.max_claimants ?? 0;
|
||||
this.distributeType = detail.distribute_type ?? 'random';
|
||||
this.fixedAmount = detail.fixed_amount;
|
||||
this.expiresAt = detail.expires_at ? new Date(detail.expires_at) : null;
|
||||
const holidayDetail = normalizeHolidayPayload(detail);
|
||||
|
||||
this.runId = holidayDetail.run_id ?? null;
|
||||
this.legacyEventId = holidayDetail.event_id ?? null;
|
||||
this.eventName = holidayDetail.name ?? '节日福利';
|
||||
this.eventDesc = buildHolidayDescription(holidayDetail);
|
||||
this.roundLabel = buildHolidayRoundLabel(holidayDetail);
|
||||
this.totalAmount = toHolidayNumber(holidayDetail.total_amount, 0);
|
||||
this.maxClaimants = toHolidayNumber(holidayDetail.max_claimants, 0);
|
||||
this.distributeType = holidayDetail.distribute_type ?? 'random';
|
||||
this.fixedAmount = firstHolidayDefined(holidayDetail.fixed_amount, null);
|
||||
this.scheduledFor = parseHolidayDate(holidayDetail.scheduled_for);
|
||||
this.scheduledForText = formatHolidayDate(holidayDetail.scheduled_for);
|
||||
this.expiresAt = parseHolidayDate(holidayDetail.expires_at);
|
||||
this.claimable = true;
|
||||
this.claimed = false;
|
||||
this.loadingStatus = false;
|
||||
this.claiming = false;
|
||||
this.claimedAmount = 0;
|
||||
this.statusHint = holidayDetail.run_id
|
||||
? '当前奖励按本轮福利批次发放,请在有效期内领取。'
|
||||
: '兼容旧活动通道,等待主线广播升级';
|
||||
this.updateExpiresIn();
|
||||
this.startExpiresTimer();
|
||||
this.show = true;
|
||||
this.syncStatus();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -153,69 +340,222 @@
|
||||
*/
|
||||
close() {
|
||||
this.show = false;
|
||||
this.stopExpiresTimer();
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动倒计时刷新
|
||||
*/
|
||||
startExpiresTimer() {
|
||||
this.stopExpiresTimer();
|
||||
this.expiresTimer = window.setInterval(() => this.updateExpiresIn(), 30000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 停止倒计时刷新
|
||||
*/
|
||||
stopExpiresTimer() {
|
||||
if (this.expiresTimer) {
|
||||
window.clearInterval(this.expiresTimer);
|
||||
this.expiresTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新有效期显示文字
|
||||
*/
|
||||
updateExpiresIn() {
|
||||
if (!this.expiresAt) {
|
||||
this.expiresIn = '30 分钟';
|
||||
this.expiresIn = formatHolidayRemaining(this.expiresAt);
|
||||
},
|
||||
|
||||
/**
|
||||
* 构建状态接口候选地址
|
||||
*/
|
||||
buildStatusUrls() {
|
||||
const urls = [];
|
||||
|
||||
if (this.runId) {
|
||||
urls.push(`/holiday/runs/${encodeURIComponent(this.runId)}/status`);
|
||||
}
|
||||
|
||||
if (this.legacyEventId) {
|
||||
urls.push(`/holiday/${encodeURIComponent(this.legacyEventId)}/status`);
|
||||
}
|
||||
|
||||
return urls;
|
||||
},
|
||||
|
||||
/**
|
||||
* 构建领取接口候选地址
|
||||
*/
|
||||
buildClaimUrls() {
|
||||
const urls = [];
|
||||
|
||||
if (this.runId) {
|
||||
urls.push(`/holiday/runs/${encodeURIComponent(this.runId)}/claim`);
|
||||
}
|
||||
|
||||
if (this.legacyEventId) {
|
||||
urls.push(`/holiday/${encodeURIComponent(this.legacyEventId)}/claim`);
|
||||
}
|
||||
|
||||
return urls;
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取当前批次领取状态
|
||||
*/
|
||||
async syncStatus() {
|
||||
const statusUrls = this.buildStatusUrls();
|
||||
|
||||
if (statusUrls.length === 0) {
|
||||
this.claimable = false;
|
||||
this.statusHint = '缺少 run_id,无法查询当前福利状态。';
|
||||
return;
|
||||
}
|
||||
const diff = Math.max(0, Math.round((this.expiresAt - Date.now()) / 60000));
|
||||
this.expiresIn = diff > 0 ? diff + ' 分钟' : '即将过期';
|
||||
|
||||
this.loadingStatus = true;
|
||||
|
||||
try {
|
||||
for (const url of statusUrls) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.applyStatus(data);
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusHint = '状态接口尚未切换完成,可直接尝试领取。';
|
||||
} catch {
|
||||
this.statusHint = '状态同步失败,可直接尝试领取。';
|
||||
} finally {
|
||||
this.loadingStatus = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 应用后端返回的状态快照
|
||||
*/
|
||||
applyStatus(data) {
|
||||
if (data.expires_at) {
|
||||
this.expiresAt = parseHolidayDate(data.expires_at);
|
||||
this.updateExpiresIn();
|
||||
}
|
||||
|
||||
const statusValue = firstHolidayDefined(data.status, data.claim_status);
|
||||
const alreadyClaimed = Boolean(data.claimed ?? data.has_claimed ?? false) || ['claimed', 'received', 'paid']
|
||||
.includes(statusValue);
|
||||
const amount = toHolidayNumber(firstHolidayDefined(data.claimed_amount, data.amount), 0);
|
||||
|
||||
if (alreadyClaimed) {
|
||||
this.claimed = true;
|
||||
this.claimable = false;
|
||||
this.claimedAmount = amount;
|
||||
this.statusHint = data.message ?? '本轮福利已领取。';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.claimable === false || data.can_claim === false) {
|
||||
this.claimable = false;
|
||||
this.statusHint = data.message ?? '当前不在可领取名单或领取窗口已关闭。';
|
||||
return;
|
||||
}
|
||||
|
||||
this.claimable = true;
|
||||
this.statusHint = data.message ?? this.statusHint;
|
||||
},
|
||||
|
||||
/**
|
||||
* 发起领取请求
|
||||
*/
|
||||
async doClaim() {
|
||||
if (this.claiming || this.claimed || !this.eventId) return;
|
||||
if (this.claiming || this.claimed || !this.claimable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const claimUrls = this.buildClaimUrls();
|
||||
|
||||
if (claimUrls.length === 0) {
|
||||
window.chatDialog?.alert('缺少福利批次标识,暂时无法领取。', '提示', '#f59e0b');
|
||||
return;
|
||||
}
|
||||
|
||||
this.claiming = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/holiday/${this.eventId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
},
|
||||
});
|
||||
const data = await res.json();
|
||||
for (const url of claimUrls) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.ok) {
|
||||
this.claimed = true;
|
||||
this.claimedAmount = data.amount || 0;
|
||||
} else {
|
||||
window.chatDialog?.alert(data.message || '领取失败,请稍后重试。', '提示', '#f59e0b');
|
||||
if (data.message?.includes('已结束') || data.message?.includes('过期')) {
|
||||
this.close();
|
||||
if (res.status === 404 || res.status === 405) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const claimedAmount = toHolidayNumber(firstHolidayDefined(data.claimed_amount, data.amount), 0);
|
||||
const alreadyClaimed = Boolean(data.claimed ?? data.has_claimed ?? false) || data.message?.includes('已领取');
|
||||
|
||||
if (data.ok || alreadyClaimed) {
|
||||
this.claimed = true;
|
||||
this.claimable = false;
|
||||
this.claimedAmount = claimedAmount;
|
||||
this.statusHint = data.message ?? '本轮福利已入账。';
|
||||
|
||||
if (window.__chatUser && data.balance !== undefined) {
|
||||
window.__chatUser.jjb = data.balance;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusHint = data.message ?? '领取失败,请稍后重试。';
|
||||
window.chatDialog?.alert(this.statusHint, '提示', '#f59e0b');
|
||||
|
||||
if (data.message?.includes('已结束') || data.message?.includes('过期')) {
|
||||
this.claimable = false;
|
||||
this.close();
|
||||
} else {
|
||||
this.syncStatus();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.chatDialog?.alert('领取接口尚未切换完成,请稍后再试。', '提示', '#f59e0b');
|
||||
} catch {
|
||||
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#cc4444');
|
||||
} finally {
|
||||
this.claiming = false;
|
||||
}
|
||||
|
||||
this.claiming = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── WebSocket 事件监听:节日福利开始 ─────────────────────────
|
||||
window.addEventListener('chat:holiday.started', (e) => {
|
||||
const detail = e.detail;
|
||||
const detail = normalizeHolidayPayload(e.detail);
|
||||
|
||||
// 公屏追加系统消息
|
||||
if (typeof appendSystemMessage === 'function') {
|
||||
const typeLabel = detail.distribute_type === 'fixed' ?
|
||||
`每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币` :
|
||||
'随机金额';
|
||||
const quotaText = detail.max_claimants > 0 ? `前 ${detail.max_claimants} 名` : '全体';
|
||||
appendSystemMessage(
|
||||
`🎊 【${detail.name}】节日福利开始啦!总奖池 💰${Number(detail.total_amount).toLocaleString()} 金币,${typeLabel},${quotaText}在线用户可领取!`
|
||||
);
|
||||
appendSystemMessage(buildHolidaySystemMessage({
|
||||
...detail,
|
||||
round_label: buildHolidayRoundLabel(detail),
|
||||
}));
|
||||
}
|
||||
|
||||
// 弹出全屏领取弹窗
|
||||
|
||||
@@ -45,6 +45,14 @@ Schedule::call(function () {
|
||||
->each(fn ($e) => \App\Jobs\TriggerHolidayEventJob::dispatch($e));
|
||||
})->everyMinute()->name('holiday-events:trigger')->withoutOverlapping();
|
||||
|
||||
// 每分钟:收尾已过期但尚未结算的节日福利批次
|
||||
Schedule::call(function () {
|
||||
\App\Models\HolidayEventRun::pendingToExpire()
|
||||
->each(function (\App\Models\HolidayEventRun $run): void {
|
||||
$run->update(['status' => 'expired']);
|
||||
});
|
||||
})->everyMinute()->name('holiday-event-runs:expire')->withoutOverlapping();
|
||||
|
||||
// 每分钟:推进百家乐买单活动状态(开始 / 等待结算 / 开放领取 / 过期收尾)
|
||||
Schedule::call(function () {
|
||||
app(\App\Services\BaccaratLossCoverService::class)->tick();
|
||||
|
||||
+4
-4
@@ -155,10 +155,10 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
|
||||
// ── 节日福利(前台)──────────────────────────────────────────────
|
||||
Route::prefix('holiday')->name('holiday.')->group(function () {
|
||||
// 领取节日福利红包
|
||||
Route::post('/{event}/claim', [\App\Http\Controllers\HolidayController::class, 'claim'])->name('claim');
|
||||
// 查询当前用户在活动中的领取状态
|
||||
Route::get('/{event}/status', [\App\Http\Controllers\HolidayController::class, 'status'])->name('status');
|
||||
// 领取指定发放批次的节日福利红包。
|
||||
Route::post('/runs/{run}/claim', [\App\Http\Controllers\HolidayController::class, 'claim'])->name('claim');
|
||||
// 查询当前用户在某个发放批次中的领取状态。
|
||||
Route::get('/runs/{run}/status', [\App\Http\Controllers\HolidayController::class, 'status'])->name('status');
|
||||
});
|
||||
|
||||
// ── 百家乐(前台)────────────────────────────────────────────────
|
||||
|
||||
@@ -1,139 +1,194 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利前台领取功能测试
|
||||
*
|
||||
* 覆盖按批次查询状态、领取成功、名单缺失与过期拒绝等核心路径,
|
||||
* 确保 run_id 改造后前台接口仍能正确工作。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\HolidayClaim;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证节日福利前台领取接口在批次模式下的行为。
|
||||
*/
|
||||
class HolidayControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_can_check_holiday_status()
|
||||
/**
|
||||
* 方法功能:验证用户可以查询指定福利批次的待领取状态。
|
||||
*/
|
||||
public function test_can_check_holiday_run_status(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create();
|
||||
$event = HolidayEvent::create([
|
||||
'name' => 'Test Holiday',
|
||||
'status' => 'active',
|
||||
'expires_at' => now()->addDays(1),
|
||||
'max_claimants' => 10,
|
||||
'claimed_amount' => 0,
|
||||
'total_amount' => 5000,
|
||||
'distribute_type' => 'fixed',
|
||||
'send_at' => now(),
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
]);
|
||||
[$event, $run] = $this->createActiveRun();
|
||||
|
||||
HolidayClaim::create([
|
||||
'event_id' => $event->id,
|
||||
'run_id' => $run->id,
|
||||
'user_id' => $user->id,
|
||||
'amount' => 500,
|
||||
'claimed_at' => now(),
|
||||
'claimed_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson(route('holiday.status', ['event' => $event->id]));
|
||||
$response = $this->actingAs($user)->getJson(route('holiday.status', ['run' => $run->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'claimable' => true,
|
||||
'claimed' => false,
|
||||
'amount' => 500,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_claim_holiday_bonus()
|
||||
/**
|
||||
* 方法功能:验证用户领取批次福利后会入账并写回领取状态。
|
||||
*/
|
||||
public function test_can_claim_holiday_bonus_from_run(): void
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create(['jjb' => 100]);
|
||||
$event = HolidayEvent::create([
|
||||
'name' => 'Test Holiday',
|
||||
'status' => 'active',
|
||||
'expires_at' => now()->addDays(1),
|
||||
'max_claimants' => 10,
|
||||
'claimed_amount' => 0,
|
||||
'total_amount' => 5000,
|
||||
'distribute_type' => 'fixed',
|
||||
'send_at' => now(),
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
[$event, $run] = $this->createActiveRun();
|
||||
|
||||
HolidayClaim::create([
|
||||
'event_id' => $event->id,
|
||||
'run_id' => $run->id,
|
||||
'user_id' => $user->id,
|
||||
'amount' => 500,
|
||||
'claimed_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('holiday.claim', ['run' => $run->id]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'ok' => true,
|
||||
'amount' => 500,
|
||||
]);
|
||||
|
||||
$this->assertSame(600, (int) $user->fresh()->jjb);
|
||||
|
||||
$this->assertDatabaseHas('holiday_claims', [
|
||||
'event_id' => $event->id,
|
||||
'run_id' => $run->id,
|
||||
'user_id' => $user->id,
|
||||
'amount' => 500,
|
||||
]);
|
||||
|
||||
$claimedRow = HolidayClaim::query()
|
||||
->where('run_id', $run->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($claimedRow?->claimed_at);
|
||||
$this->assertSame('completed', $run->fresh()?->status);
|
||||
$this->assertSame(1, (int) $run->fresh()?->claimed_count);
|
||||
$this->assertSame(500, (int) $run->fresh()?->claimed_amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证不在批次名单中的用户不能领取福利。
|
||||
*/
|
||||
public function test_cannot_claim_if_not_in_run_list(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
[, $run] = $this->createActiveRun();
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('holiday.claim', ['run' => $run->id]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'ok' => false,
|
||||
'message' => '您不在本次福利名单内,或活动已结束。',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证已过期批次会拒绝领取。
|
||||
*/
|
||||
public function test_cannot_claim_if_run_is_expired(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
[$event, $run] = $this->createActiveRun([
|
||||
'status' => 'expired',
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
HolidayClaim::create([
|
||||
'event_id' => $event->id,
|
||||
'run_id' => $run->id,
|
||||
'user_id' => $user->id,
|
||||
'amount' => 500,
|
||||
'claimed_at' => now(),
|
||||
'claimed_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('holiday.claim', ['event' => $event->id]));
|
||||
$response = $this->actingAs($user)->postJson(route('holiday.claim', ['run' => $run->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['ok' => true]);
|
||||
|
||||
// Verify currency incremented
|
||||
$this->assertEquals(600, $user->fresh()->jjb);
|
||||
|
||||
// Verify claim is deleted
|
||||
$this->assertDatabaseMissing('holiday_claims', [
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
$response->assertOk();
|
||||
$response->assertJson([
|
||||
'ok' => false,
|
||||
'message' => '活动已结束或已过期。',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_claim_if_not_in_list()
|
||||
/**
|
||||
* 方法功能:创建一个默认可领取的节日福利模板与发放批次。
|
||||
*
|
||||
* @param array<string, mixed> $runOverrides
|
||||
* @return array{0: HolidayEvent, 1: HolidayEventRun}
|
||||
*/
|
||||
private function createActiveRun(array $runOverrides = []): array
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create();
|
||||
$event = HolidayEvent::create([
|
||||
'name' => 'Test Holiday',
|
||||
'name' => '元旦快乐',
|
||||
'description' => '测试节日福利模板',
|
||||
'status' => 'pending',
|
||||
'enabled' => true,
|
||||
'expires_at' => null,
|
||||
'max_claimants' => 10,
|
||||
'claimed_amount' => 0,
|
||||
'claimed_count' => 0,
|
||||
'total_amount' => 5000,
|
||||
'min_amount' => 100,
|
||||
'max_amount' => 1000,
|
||||
'fixed_amount' => 500,
|
||||
'distribute_type' => 'fixed',
|
||||
'expire_minutes' => 30,
|
||||
'send_at' => now(),
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
]);
|
||||
|
||||
$run = HolidayEventRun::create(array_merge([
|
||||
'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' => null,
|
||||
'repeat_type' => $event->repeat_type,
|
||||
'scheduled_for' => now(),
|
||||
'triggered_at' => now(),
|
||||
'expires_at' => now()->addDay(),
|
||||
'status' => 'active',
|
||||
'expires_at' => now()->addDays(1),
|
||||
'max_claimants' => 10,
|
||||
'audience_count' => 0,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'total_amount' => 5000,
|
||||
'distribute_type' => 'fixed',
|
||||
'send_at' => now(),
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
]);
|
||||
], $runOverrides));
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('holiday.claim', ['event' => $event->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||
}
|
||||
|
||||
public function test_cannot_claim_if_expired()
|
||||
{
|
||||
/** @var \App\Models\User $user */
|
||||
$user = User::factory()->create();
|
||||
$event = HolidayEvent::create([
|
||||
'name' => 'Test Holiday',
|
||||
'status' => 'completed',
|
||||
'expires_at' => now()->subDays(1),
|
||||
'max_claimants' => 10,
|
||||
'claimed_amount' => 0,
|
||||
'total_amount' => 5000,
|
||||
'distribute_type' => 'fixed',
|
||||
'send_at' => now(),
|
||||
'repeat_type' => 'once',
|
||||
'target_type' => 'all',
|
||||
]);
|
||||
|
||||
HolidayClaim::create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'amount' => 500,
|
||||
'claimed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('holiday.claim', ['event' => $event->id]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||
return [$event, $run];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:节日福利调度与触发任务测试
|
||||
*
|
||||
* 覆盖年度节日多天/多轮推导、手动立即触发与每轮重新计算名单等关键行为,
|
||||
* 确保模板调度与发放批次生成逻辑稳定。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\HolidayEventStarted;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Jobs\TriggerHolidayEventJob;
|
||||
use App\Models\HolidayEvent;
|
||||
use App\Models\HolidayEventRun;
|
||||
use App\Models\User;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\HolidayEventScheduleService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证节日福利模板的调度推导和批次生成逻辑。
|
||||
*/
|
||||
class HolidayEventSchedulingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 方法功能:验证年度节日可跨连续多天推进,并在最后一天后滚到下一年。
|
||||
*/
|
||||
public function test_yearly_schedule_advances_across_days_then_rolls_to_next_year(): void
|
||||
{
|
||||
$service = app(HolidayEventScheduleService::class);
|
||||
|
||||
$event = $this->createYearlyEvent([
|
||||
'schedule_month' => 1,
|
||||
'schedule_day' => 1,
|
||||
'schedule_time' => '09:00',
|
||||
'duration_days' => 3,
|
||||
'daily_occurrences' => 1,
|
||||
'occurrence_interval_minutes' => null,
|
||||
'send_at' => '2026-01-01 09:00:00',
|
||||
]);
|
||||
|
||||
$nextDay = $service->advanceAfterTrigger($event);
|
||||
$this->assertSame('2026-01-02 09:00:00', $nextDay?->format('Y-m-d H:i:s'));
|
||||
|
||||
$event->forceFill(['send_at' => CarbonImmutable::parse('2026-01-03 09:00:00')]);
|
||||
$nextYear = $service->advanceAfterTrigger($event);
|
||||
|
||||
$this->assertSame('2027-01-01 09:00:00', $nextYear?->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证年度节日支持同一天多轮发送并在最后一轮后切到下一年。
|
||||
*/
|
||||
public function test_yearly_schedule_advances_across_same_day_multiple_occurrences(): void
|
||||
{
|
||||
$service = app(HolidayEventScheduleService::class);
|
||||
|
||||
$event = $this->createYearlyEvent([
|
||||
'schedule_month' => 8,
|
||||
'schedule_day' => 1,
|
||||
'schedule_time' => '09:00',
|
||||
'duration_days' => 1,
|
||||
'daily_occurrences' => 3,
|
||||
'occurrence_interval_minutes' => 180,
|
||||
'send_at' => '2026-08-01 09:00:00',
|
||||
]);
|
||||
|
||||
$secondRound = $service->advanceAfterTrigger($event);
|
||||
$this->assertSame('2026-08-01 12:00:00', $secondRound?->format('Y-m-d H:i:s'));
|
||||
|
||||
$event->forceFill(['send_at' => CarbonImmutable::parse('2026-08-01 12:00:00')]);
|
||||
$thirdRound = $service->advanceAfterTrigger($event);
|
||||
$this->assertSame('2026-08-01 15:00:00', $thirdRound?->format('Y-m-d H:i:s'));
|
||||
|
||||
$event->forceFill(['send_at' => CarbonImmutable::parse('2026-08-01 15:00:00')]);
|
||||
$nextYear = $service->advanceAfterTrigger($event);
|
||||
|
||||
$this->assertSame('2027-08-01 09:00:00', $nextYear?->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证手动立即触发的每一轮都会重新按当时在线用户生成名单。
|
||||
*/
|
||||
public function test_manual_trigger_recomputes_audience_for_each_run(): void
|
||||
{
|
||||
Event::fake([HolidayEventStarted::class, MessageSent::class]);
|
||||
Queue::fake([SaveMessageJob::class]);
|
||||
|
||||
$firstUser = User::factory()->create(['username' => 'holiday-a']);
|
||||
$secondUser = User::factory()->create(['username' => 'holiday-b']);
|
||||
|
||||
Redis::shouldReceive('hgetall')
|
||||
->twice()
|
||||
->andReturn(
|
||||
['holiday-a' => json_encode(['user_id' => $firstUser->id], JSON_UNESCAPED_UNICODE)],
|
||||
['holiday-b' => json_encode(['user_id' => $secondUser->id], JSON_UNESCAPED_UNICODE)],
|
||||
);
|
||||
|
||||
$chatState = $this->createMock(ChatStateService::class);
|
||||
$chatState->expects($this->exactly(2))->method('nextMessageId')->with(1)->willReturnOnConsecutiveCalls(1, 2);
|
||||
$chatState->expects($this->exactly(2))->method('pushMessage');
|
||||
|
||||
$event = $this->createYearlyEvent([
|
||||
'send_at' => now()->addDay()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
(new TriggerHolidayEventJob($event, true))->handle($chatState, app(HolidayEventScheduleService::class));
|
||||
(new TriggerHolidayEventJob($event, true))->handle($chatState, app(HolidayEventScheduleService::class));
|
||||
|
||||
$runs = HolidayEventRun::query()->orderBy('id')->get();
|
||||
|
||||
$this->assertCount(2, $runs);
|
||||
$this->assertDatabaseHas('holiday_claims', ['run_id' => $runs[0]->id, 'user_id' => $firstUser->id]);
|
||||
$this->assertDatabaseHas('holiday_claims', ['run_id' => $runs[1]->id, 'user_id' => $secondUser->id]);
|
||||
$this->assertDatabaseMissing('holiday_claims', ['run_id' => $runs[0]->id, 'user_id' => $secondUser->id]);
|
||||
$this->assertDatabaseMissing('holiday_claims', ['run_id' => $runs[1]->id, 'user_id' => $firstUser->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证手动立即触发不会破坏年度模板原本的 send_at 锚点。
|
||||
*/
|
||||
public function test_manual_trigger_does_not_change_yearly_anchor_send_at(): void
|
||||
{
|
||||
Event::fake([HolidayEventStarted::class, MessageSent::class]);
|
||||
Queue::fake([SaveMessageJob::class]);
|
||||
Redis::shouldReceive('hgetall')->once()->andReturn([]);
|
||||
|
||||
$chatState = $this->createMock(ChatStateService::class);
|
||||
$event = $this->createYearlyEvent([
|
||||
'send_at' => '2026-08-01 09:00:00',
|
||||
]);
|
||||
|
||||
(new TriggerHolidayEventJob($event, true))->handle($chatState, app(HolidayEventScheduleService::class));
|
||||
|
||||
$this->assertSame('2026-08-01 09:00:00', $event->fresh()?->send_at?->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:验证旧的 daily 重复模式仍按原逻辑继续推进。
|
||||
*/
|
||||
public function test_daily_repeat_mode_still_advances_normally(): void
|
||||
{
|
||||
$service = app(HolidayEventScheduleService::class);
|
||||
|
||||
$event = HolidayEvent::create([
|
||||
'name' => '普通重复福利',
|
||||
'description' => 'daily 兼容测试',
|
||||
'total_amount' => 5000,
|
||||
'max_claimants' => 20,
|
||||
'distribute_type' => 'fixed',
|
||||
'min_amount' => 100,
|
||||
'max_amount' => null,
|
||||
'fixed_amount' => 500,
|
||||
'send_at' => CarbonImmutable::parse('2026-05-01 09:00:00'),
|
||||
'expire_minutes' => 30,
|
||||
'repeat_type' => 'daily',
|
||||
'target_type' => 'all',
|
||||
'status' => 'pending',
|
||||
'enabled' => true,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
'duration_days' => 1,
|
||||
'daily_occurrences' => 1,
|
||||
]);
|
||||
|
||||
$nextSendAt = $service->advanceAfterTrigger($event);
|
||||
|
||||
$this->assertSame('2026-05-02 09:00:00', $nextSendAt?->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法功能:创建一个年度节日模板。
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
private function createYearlyEvent(array $overrides = []): HolidayEvent
|
||||
{
|
||||
return HolidayEvent::create(array_merge([
|
||||
'name' => '建军节福利',
|
||||
'description' => '年度节日调度测试',
|
||||
'total_amount' => 9000,
|
||||
'max_claimants' => 30,
|
||||
'distribute_type' => 'fixed',
|
||||
'min_amount' => 100,
|
||||
'max_amount' => null,
|
||||
'fixed_amount' => 300,
|
||||
'send_at' => '2026-08-01 09:00:00',
|
||||
'expire_minutes' => 30,
|
||||
'repeat_type' => 'yearly',
|
||||
'schedule_month' => 8,
|
||||
'schedule_day' => 1,
|
||||
'schedule_time' => '09:00',
|
||||
'duration_days' => 1,
|
||||
'daily_occurrences' => 1,
|
||||
'occurrence_interval_minutes' => null,
|
||||
'target_type' => 'all',
|
||||
'target_value' => null,
|
||||
'status' => 'pending',
|
||||
'enabled' => true,
|
||||
'claimed_count' => 0,
|
||||
'claimed_amount' => 0,
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user