升级节日福利年度调度与批次领取

This commit is contained in:
2026-04-21 17:53:11 +08:00
parent 5a6446b832
commit a066580014
25 changed files with 2362 additions and 536 deletions
@@ -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;
}
}
+62 -38
View File
@@ -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 {}