Add baccarat loss cover activity
This commit is contained in:
@@ -84,6 +84,9 @@ enum CurrencySource: string
|
||||
/** 百家乐中奖赔付(收入金币,含本金返还) */
|
||||
case BACCARAT_WIN = 'baccarat_win';
|
||||
|
||||
/** 百家乐买单活动补偿领取(收入金币) */
|
||||
case BACCARAT_LOSS_COVER_CLAIM = 'baccarat_loss_cover_claim';
|
||||
|
||||
/** 星海小博士随机事件(好运/坏运/经验/金币奖惩) */
|
||||
case AUTO_EVENT = 'auto_event';
|
||||
|
||||
@@ -162,6 +165,7 @@ enum CurrencySource: string
|
||||
self::HOLIDAY_BONUS => '节日福利',
|
||||
self::BACCARAT_BET => '百家乐下注',
|
||||
self::BACCARAT_WIN => '百家乐赢钱',
|
||||
self::BACCARAT_LOSS_COVER_CLAIM => '百家乐买单活动补偿',
|
||||
self::AUTO_EVENT => '随机事件(星海小博士)',
|
||||
self::SLOT_SPIN => '老虎机转动',
|
||||
self::SLOT_WIN => '老虎机中奖',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动后台控制器
|
||||
*
|
||||
* 提供聊天室管理员在输入框上方快捷创建活动、
|
||||
* 查看当前活动并手动结束活动的 JSON 接口。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreBaccaratLossCoverEventRequest;
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BaccaratLossCoverEventController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入百家乐买单活动服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新的百家乐买单活动。
|
||||
*/
|
||||
public function store(StoreBaccaratLossCoverEventRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$event = $this->lossCoverService->createEvent($request->user(), $request->validated());
|
||||
} catch (\RuntimeException $exception) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => "活动「{$event->title}」已创建成功。",
|
||||
'event_id' => $event->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动结束或取消一场百家乐买单活动。
|
||||
*/
|
||||
public function close(Request $request, BaccaratLossCoverEvent $event): JsonResponse
|
||||
{
|
||||
$event = $this->lossCoverService->forceCloseEvent($event, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '活动状态已更新。',
|
||||
'status' => $event->status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Enums\CurrencySource;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -28,6 +29,7 @@ class BaccaratController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserCurrencyService $currency,
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -107,8 +109,9 @@ class BaccaratController extends Controller
|
||||
}
|
||||
|
||||
$currency = $this->currency;
|
||||
$lossCoverService = $this->lossCoverService;
|
||||
|
||||
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
|
||||
return DB::transaction(function () use ($user, $round, $data, $currency, $lossCoverService): JsonResponse {
|
||||
// 幂等:同一局只能下一注
|
||||
$existing = BaccaratBet::query()
|
||||
->where('round_id', $round->id)
|
||||
@@ -131,15 +134,22 @@ class BaccaratController extends Controller
|
||||
},
|
||||
);
|
||||
|
||||
// 下注时间命中活动窗口时,将本次下注挂到对应的买单活动下。
|
||||
$lossCoverEvent = $lossCoverService->findEventForBetTime(now());
|
||||
|
||||
// 写入下注记录
|
||||
BaccaratBet::create([
|
||||
$bet = BaccaratBet::create([
|
||||
'round_id' => $round->id,
|
||||
'user_id' => $user->id,
|
||||
'loss_cover_event_id' => $lossCoverEvent?->id,
|
||||
'bet_type' => $data['bet_type'],
|
||||
'amount' => $data['amount'],
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// 命中活动的下注要同步累计到用户活动记录中,便于后续前台查看。
|
||||
$lossCoverService->registerBet($bet);
|
||||
|
||||
// 更新局次汇总统计
|
||||
$field = 'total_bet_'.$data['bet_type'];
|
||||
$countField = 'bet_count_'.$data['bet_type'];
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动前台控制器
|
||||
*
|
||||
* 提供活动摘要、历史记录以及用户领取补偿的接口,
|
||||
* 供娱乐大厅弹窗与聊天室系统消息按钮调用。
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BaccaratLossCoverController extends Controller
|
||||
{
|
||||
/**
|
||||
* 注入百家乐买单活动服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BaccaratLossCoverService $lossCoverService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 返回当前最值得关注的一次活动摘要。
|
||||
*/
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$event = BaccaratLossCoverEvent::query()
|
||||
->with(['creator:id,username'])
|
||||
->whereIn('status', ['active', 'settlement_pending', 'claimable', 'scheduled'])
|
||||
->orderByRaw("CASE status WHEN 'active' THEN 0 WHEN 'settlement_pending' THEN 1 WHEN 'claimable' THEN 2 WHEN 'scheduled' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('starts_at')
|
||||
->first();
|
||||
|
||||
$record = null;
|
||||
if ($event) {
|
||||
$record = $event->records()->where('user_id', $request->user()->id)->first();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'event' => $event ? $this->transformEvent($event, $record) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回最近的活动列表以及当前用户的领取记录。
|
||||
*/
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
$events = BaccaratLossCoverEvent::query()
|
||||
->with(['creator:id,username', 'records' => function ($query) use ($request) {
|
||||
$query->where('user_id', $request->user()->id);
|
||||
}])
|
||||
->latest('starts_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(function (BaccaratLossCoverEvent $event) {
|
||||
$record = $event->records->first();
|
||||
|
||||
return $this->transformEvent($event, $record);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取指定活动的补偿金币。
|
||||
*/
|
||||
public function claim(Request $request, BaccaratLossCoverEvent $event): JsonResponse
|
||||
{
|
||||
$result = $this->lossCoverService->claim($event, $request->user());
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将活动与个人记录整理为前端更容易消费的结构。
|
||||
*/
|
||||
private function transformEvent(BaccaratLossCoverEvent $event, mixed $record): array
|
||||
{
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'description' => $event->description,
|
||||
'status' => $event->status,
|
||||
'status_label' => $event->statusLabel(),
|
||||
'starts_at' => $event->starts_at?->toIso8601String(),
|
||||
'ends_at' => $event->ends_at?->toIso8601String(),
|
||||
'claim_deadline_at' => $event->claim_deadline_at?->toIso8601String(),
|
||||
'participant_count' => $event->participant_count,
|
||||
'compensable_user_count' => $event->compensable_user_count,
|
||||
'total_loss_amount' => $event->total_loss_amount,
|
||||
'total_claimed_amount' => $event->total_claimed_amount,
|
||||
'creator_username' => $event->creator?->username ?? '管理员',
|
||||
'my_record' => $record ? [
|
||||
'total_bet_amount' => $record->total_bet_amount,
|
||||
'total_win_payout' => $record->total_win_payout,
|
||||
'total_loss_amount' => $record->total_loss_amount,
|
||||
'compensation_amount' => $record->compensation_amount,
|
||||
'claim_status' => $record->claim_status,
|
||||
'claim_status_label' => $record->claimStatusLabel(),
|
||||
'claimed_amount' => $record->claimed_amount,
|
||||
'claimed_at' => $record->claimed_at?->toIso8601String(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动创建请求
|
||||
*
|
||||
* 负责校验聊天室管理员在前台创建买单活动时提交的时间与文案字段。
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreBaccaratLossCoverEventRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 判断当前用户是否允许提交创建请求。
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段校验规则。
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'starts_at' => ['required', 'date'],
|
||||
'ends_at' => ['required', 'date', 'after:starts_at'],
|
||||
'claim_deadline_at' => ['required', 'date', 'after_or_equal:ends_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中文错误提示。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '请输入活动标题',
|
||||
'starts_at.required' => '请选择活动开始时间',
|
||||
'ends_at.required' => '请选择活动结束时间',
|
||||
'ends_at.after' => '活动结束时间必须晚于开始时间',
|
||||
'claim_deadline_at.required' => '请选择领取截止时间',
|
||||
'claim_deadline_at.after_or_equal' => '领取截止时间不能早于活动结束时间',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Events\MessageSent;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Services\BaccaratLossCoverService;
|
||||
use App\Services\ChatStateService;
|
||||
use App\Services\UserCurrencyService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -49,6 +50,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
public function handle(
|
||||
UserCurrencyService $currency,
|
||||
ChatStateService $chatState,
|
||||
BaccaratLossCoverService $lossCoverService,
|
||||
): void {
|
||||
$round = $this->round->fresh();
|
||||
|
||||
@@ -95,7 +97,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
$winners = [];
|
||||
$losers = [];
|
||||
|
||||
DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout, &$winners, &$losers) {
|
||||
DB::transaction(function () use ($bets, $result, $config, $currency, $lossCoverService, &$totalPayout, &$winners, &$losers) {
|
||||
foreach ($bets as $bet) {
|
||||
/** @var \App\Models\BaccaratBet $bet */
|
||||
$username = $bet->user->username ?? '匿名';
|
||||
@@ -103,6 +105,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
if ($result === 'kill') {
|
||||
// 庄家收割:全灭无退款
|
||||
$bet->update(['status' => 'lost', 'payout' => 0]);
|
||||
$lossCoverService->registerSettlement($bet->fresh());
|
||||
$losers[] = "{$username}-{$bet->amount}";
|
||||
|
||||
if ($username === 'AI小班长') {
|
||||
@@ -126,6 +129,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
"百家乐 #{$this->round->id} 押 {$bet->betTypeLabel()} 中奖",
|
||||
);
|
||||
$totalPayout += $payout;
|
||||
$lossCoverService->registerSettlement($bet->fresh());
|
||||
$winners[] = "{$username}+".number_format($payout);
|
||||
|
||||
if ($username === 'AI小班长') {
|
||||
@@ -133,6 +137,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
}
|
||||
} else {
|
||||
$bet->update(['status' => 'lost', 'payout' => 0]);
|
||||
$lossCoverService->registerSettlement($bet->fresh());
|
||||
$losers[] = "{$username}-".number_format($bet->amount);
|
||||
|
||||
if ($username === 'AI小班长') {
|
||||
@@ -227,7 +232,7 @@ class CloseBaccaratRoundJob implements ShouldQueue
|
||||
|
||||
// 触发微信机器人消息推送 (百家乐结果,无人参与时不推送微信群防止刷屏)
|
||||
try {
|
||||
if (!empty($winners) || !empty($losers)) {
|
||||
if (! empty($winners) || ! empty($losers)) {
|
||||
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
|
||||
$wechatService->notifyBaccaratResult($content);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* 文件功能:百家乐下注记录模型
|
||||
*
|
||||
* 记录用户在某局中的押注信息和结算状态。
|
||||
* 记录用户在某局中的押注信息、结算状态以及关联的买单活动。
|
||||
*
|
||||
* @author ChatRoom Laravel
|
||||
*
|
||||
@@ -20,6 +20,7 @@ class BaccaratBet extends Model
|
||||
protected $fillable = [
|
||||
'round_id',
|
||||
'user_id',
|
||||
'loss_cover_event_id',
|
||||
'bet_type',
|
||||
'amount',
|
||||
'payout',
|
||||
@@ -32,6 +33,7 @@ class BaccaratBet extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'loss_cover_event_id' => 'integer',
|
||||
'amount' => 'integer',
|
||||
'payout' => 'integer',
|
||||
];
|
||||
@@ -53,6 +55,14 @@ class BaccaratBet extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联参与的百家乐买单活动。
|
||||
*/
|
||||
public function lossCoverEvent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BaccaratLossCoverEvent::class, 'loss_cover_event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取押注类型中文名。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?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 BaccaratLossCoverEvent extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\BaccaratLossCoverEventFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'claim_deadline_at',
|
||||
'created_by_user_id',
|
||||
'closed_by_user_id',
|
||||
'started_notice_sent_at',
|
||||
'ended_notice_sent_at',
|
||||
'participant_count',
|
||||
'compensable_user_count',
|
||||
'total_loss_amount',
|
||||
'total_claimed_amount',
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段类型转换。
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'claim_deadline_at' => 'datetime',
|
||||
'started_notice_sent_at' => 'datetime',
|
||||
'ended_notice_sent_at' => 'datetime',
|
||||
'participant_count' => 'integer',
|
||||
'compensable_user_count' => 'integer',
|
||||
'total_loss_amount' => 'integer',
|
||||
'total_claimed_amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:开启活动的用户。
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:结束活动的用户。
|
||||
*/
|
||||
public function closer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'closed_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:活动下的用户聚合记录。
|
||||
*/
|
||||
public function records(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaccaratLossCoverRecord::class, 'event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:活动下的百家乐下注记录。
|
||||
*/
|
||||
public function bets(): HasMany
|
||||
{
|
||||
return $this->hasMany(BaccaratBet::class, 'loss_cover_event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断活动当前是否允许领取补偿。
|
||||
*/
|
||||
public function isClaimable(): bool
|
||||
{
|
||||
return $this->status === 'claimable'
|
||||
&& $this->claim_deadline_at !== null
|
||||
&& $this->claim_deadline_at->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回活动状态中文标签。
|
||||
*/
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'scheduled' => '未开始',
|
||||
'active' => '进行中',
|
||||
'settlement_pending' => '等待结算',
|
||||
'claimable' => '可领取',
|
||||
'completed' => '已结束',
|
||||
'cancelled' => '已取消',
|
||||
default => '未知状态',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动用户记录模型
|
||||
*
|
||||
* 保存某个用户在一次买单活动中的累计下注、输赢、
|
||||
* 可补偿金额以及最终领取结果。
|
||||
*/
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BaccaratLossCoverRecord extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\BaccaratLossCoverRecordFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* 允许批量赋值的字段。
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'user_id',
|
||||
'total_bet_amount',
|
||||
'total_win_payout',
|
||||
'total_loss_amount',
|
||||
'compensation_amount',
|
||||
'claim_status',
|
||||
'claimed_amount',
|
||||
'claimed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段类型转换。
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total_bet_amount' => 'integer',
|
||||
'total_win_payout' => 'integer',
|
||||
'total_loss_amount' => 'integer',
|
||||
'compensation_amount' => 'integer',
|
||||
'claimed_amount' => 'integer',
|
||||
'claimed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:所属活动。
|
||||
*/
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BaccaratLossCoverEvent::class, 'event_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联:所属用户。
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回领取状态中文标签。
|
||||
*/
|
||||
public function claimStatusLabel(): string
|
||||
{
|
||||
return match ($this->claim_status) {
|
||||
'not_eligible' => '无补偿',
|
||||
'pending' => '待领取',
|
||||
'claimed' => '已领取',
|
||||
'expired' => '已过期',
|
||||
default => '未知状态',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动服务
|
||||
*
|
||||
* 统一处理活动创建、生命周期推进、下注关联、
|
||||
* 结算累计补偿、用户领取补偿以及聊天室通知广播。
|
||||
*/
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Events\MessageSent;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\BaccaratBet;
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Models\BaccaratLossCoverRecord;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BaccaratLossCoverService
|
||||
{
|
||||
/**
|
||||
* 注入聊天室状态与金币服务。
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currency,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新的百家乐买单活动。
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function createEvent(User $operator, array $data): BaccaratLossCoverEvent
|
||||
{
|
||||
$startsAt = Carbon::parse((string) $data['starts_at']);
|
||||
$endsAt = Carbon::parse((string) $data['ends_at']);
|
||||
|
||||
if ($this->hasOverlap($startsAt, $endsAt)) {
|
||||
throw new \RuntimeException('活动时间段与其他百家乐买单活动重叠,请调整后再试。');
|
||||
}
|
||||
|
||||
$event = BaccaratLossCoverEvent::create([
|
||||
'title' => (string) $data['title'],
|
||||
'description' => $data['description'] ?: null,
|
||||
'status' => 'scheduled',
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'claim_deadline_at' => Carbon::parse((string) $data['claim_deadline_at']),
|
||||
'created_by_user_id' => $operator->id,
|
||||
]);
|
||||
|
||||
// 如果开始时间已到,则立即激活活动,避免用户等待下一次定时扫描。
|
||||
if ($event->starts_at->lte(now())) {
|
||||
$this->activateEvent($event);
|
||||
}
|
||||
|
||||
return $event->fresh(['creator', 'closer']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动结束或取消活动。
|
||||
*/
|
||||
public function forceCloseEvent(BaccaratLossCoverEvent $event, User $operator): BaccaratLossCoverEvent
|
||||
{
|
||||
if ($event->status === 'cancelled' || $event->status === 'completed') {
|
||||
return $event;
|
||||
}
|
||||
|
||||
// 未开始前手动关闭视为取消,保留完整开启档案但不参与结算。
|
||||
if ($event->status === 'scheduled' && $event->starts_at->isFuture()) {
|
||||
$event->update([
|
||||
'status' => 'cancelled',
|
||||
'closed_by_user_id' => $operator->id,
|
||||
]);
|
||||
|
||||
return $event->fresh(['creator', 'closer']);
|
||||
}
|
||||
|
||||
// 已开始的活动会被立即截断结束时间,然后走正常的结算/领取流转。
|
||||
$event->update([
|
||||
'ends_at' => now(),
|
||||
'closed_by_user_id' => $operator->id,
|
||||
]);
|
||||
|
||||
$this->transitionAfterEnd($event->fresh());
|
||||
|
||||
return $event->fresh(['creator', 'closer']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时推进所有买单活动的生命周期。
|
||||
*/
|
||||
public function tick(): void
|
||||
{
|
||||
$this->activateDueEvents();
|
||||
$this->closeDueActiveEvents();
|
||||
$this->finalizeSettlementPendingEvents();
|
||||
$this->expireClaimableEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某个下注时间点命中的活动。
|
||||
*/
|
||||
public function findEventForBetTime(?Carbon $betTime = null): ?BaccaratLossCoverEvent
|
||||
{
|
||||
$betTime = $betTime ?? now();
|
||||
|
||||
return BaccaratLossCoverEvent::query()
|
||||
->whereIn('status', ['active', 'settlement_pending', 'claimable'])
|
||||
->where('starts_at', '<=', $betTime)
|
||||
->where('ends_at', '>=', $betTime)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在下注成功后登记用户的活动参与记录。
|
||||
*/
|
||||
public function registerBet(BaccaratBet $bet): void
|
||||
{
|
||||
if (! $bet->loss_cover_event_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次命中活动的用户会创建聚合记录,并计入活动参与人数。
|
||||
$record = BaccaratLossCoverRecord::query()->firstOrCreate(
|
||||
[
|
||||
'event_id' => $bet->loss_cover_event_id,
|
||||
'user_id' => $bet->user_id,
|
||||
],
|
||||
[
|
||||
'claim_status' => 'not_eligible',
|
||||
],
|
||||
);
|
||||
|
||||
if ($record->wasRecentlyCreated) {
|
||||
BaccaratLossCoverEvent::query()
|
||||
->where('id', $bet->loss_cover_event_id)
|
||||
->increment('participant_count');
|
||||
}
|
||||
|
||||
// 每一笔命中活动的下注都要累加到账户活动统计中。
|
||||
$record->increment('total_bet_amount', $bet->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在百家乐结算后同步更新活动用户记录。
|
||||
*/
|
||||
public function registerSettlement(BaccaratBet $bet): void
|
||||
{
|
||||
if (! $bet->loss_cover_event_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = BaccaratLossCoverRecord::query()->firstOrCreate(
|
||||
[
|
||||
'event_id' => $bet->loss_cover_event_id,
|
||||
'user_id' => $bet->user_id,
|
||||
],
|
||||
[
|
||||
'claim_status' => 'not_eligible',
|
||||
],
|
||||
);
|
||||
|
||||
// 中奖只记录赔付统计,不影响补偿资格。
|
||||
if ($bet->status === 'won') {
|
||||
$record->increment('total_win_payout', $bet->payout);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($bet->status !== 'lost') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 输掉的金额就是后续可领取的补偿金额。
|
||||
$record->increment('total_loss_amount', $bet->amount);
|
||||
$record->increment('compensation_amount', $bet->amount);
|
||||
$record->update(['claim_status' => 'pending']);
|
||||
|
||||
BaccaratLossCoverEvent::query()
|
||||
->where('id', $bet->loss_cover_event_id)
|
||||
->increment('total_loss_amount', $bet->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户领取某次活动的补偿金币。
|
||||
*
|
||||
* @return array{ok: bool, message: string, amount?: int}
|
||||
*/
|
||||
public function claim(BaccaratLossCoverEvent $event, User $user): array
|
||||
{
|
||||
if (! $event->isClaimable()) {
|
||||
return ['ok' => false, 'message' => '当前活动暂未开放领取,或已超过领取时间。'];
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($event, $user): array {
|
||||
$record = BaccaratLossCoverRecord::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $record || $record->compensation_amount <= 0) {
|
||||
return ['ok' => false, 'message' => '您在本次活动中暂无可领取补偿。'];
|
||||
}
|
||||
|
||||
if ($record->claim_status === 'claimed') {
|
||||
return ['ok' => false, 'message' => '本次活动补偿您已经领取过了。'];
|
||||
}
|
||||
|
||||
if ($record->claim_status === 'expired') {
|
||||
return ['ok' => false, 'message' => '本次活动补偿已过期,无法领取。'];
|
||||
}
|
||||
|
||||
$amount = (int) $record->compensation_amount;
|
||||
|
||||
// 领取成功时必须统一走金币服务,确保 user_currency_logs 自动落账。
|
||||
$this->currency->change(
|
||||
$user,
|
||||
'gold',
|
||||
$amount,
|
||||
CurrencySource::BACCARAT_LOSS_COVER_CLAIM,
|
||||
"百家乐买单活动 #{$event->id} 领取补偿",
|
||||
);
|
||||
|
||||
$record->update([
|
||||
'claim_status' => 'claimed',
|
||||
'claimed_amount' => $amount,
|
||||
'claimed_at' => now(),
|
||||
]);
|
||||
|
||||
$event->increment('total_claimed_amount', $amount);
|
||||
|
||||
// 所有待领取记录都被领完后,可提前结束活动,方便前台展示最终状态。
|
||||
if (! BaccaratLossCoverRecord::query()->where('event_id', $event->id)->where('claim_status', 'pending')->exists()) {
|
||||
$event->update(['status' => 'completed']);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "已成功领取 {$amount} 金币补偿!",
|
||||
'amount' => $amount,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并激活到时的活动。
|
||||
*/
|
||||
public function activateDueEvents(): void
|
||||
{
|
||||
BaccaratLossCoverEvent::query()
|
||||
->where('status', 'scheduled')
|
||||
->where('starts_at', '<=', now())
|
||||
->orderBy('starts_at')
|
||||
->get()
|
||||
->each(function (BaccaratLossCoverEvent $event): void {
|
||||
$this->activateEvent($event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描已到结束时间的进行中活动。
|
||||
*/
|
||||
public function closeDueActiveEvents(): void
|
||||
{
|
||||
BaccaratLossCoverEvent::query()
|
||||
->where('status', 'active')
|
||||
->where('ends_at', '<=', now())
|
||||
->orderBy('ends_at')
|
||||
->get()
|
||||
->each(function (BaccaratLossCoverEvent $event): void {
|
||||
$this->transitionAfterEnd($event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描等待结算完成的活动并尝试开放领取。
|
||||
*/
|
||||
public function finalizeSettlementPendingEvents(): void
|
||||
{
|
||||
BaccaratLossCoverEvent::query()
|
||||
->where('status', 'settlement_pending')
|
||||
->orderBy('ends_at')
|
||||
->get()
|
||||
->each(function (BaccaratLossCoverEvent $event): void {
|
||||
$this->transitionAfterEnd($event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描补偿领取过期的活动并收尾。
|
||||
*/
|
||||
public function expireClaimableEvents(): void
|
||||
{
|
||||
BaccaratLossCoverEvent::query()
|
||||
->where('status', 'claimable')
|
||||
->where('claim_deadline_at', '<=', now())
|
||||
->orderBy('claim_deadline_at')
|
||||
->get()
|
||||
->each(function (BaccaratLossCoverEvent $event): void {
|
||||
// 超过领取时间后,未领取的记录统一标记为过期。
|
||||
BaccaratLossCoverRecord::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('claim_status', 'pending')
|
||||
->update(['claim_status' => 'expired']);
|
||||
|
||||
$event->update(['status' => 'completed']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定时间窗是否与历史或进行中的活动冲突。
|
||||
*/
|
||||
public function hasOverlap(Carbon $startsAt, Carbon $endsAt): bool
|
||||
{
|
||||
return BaccaratLossCoverEvent::query()
|
||||
->where('status', '!=', 'cancelled')
|
||||
->where('starts_at', '<', $endsAt)
|
||||
->where('ends_at', '>', $startsAt)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活单个活动并发送开始通知。
|
||||
*/
|
||||
private function activateEvent(BaccaratLossCoverEvent $event): void
|
||||
{
|
||||
$updated = BaccaratLossCoverEvent::query()
|
||||
->where('id', $event->id)
|
||||
->where('status', 'scheduled')
|
||||
->update(['status' => 'active']);
|
||||
|
||||
if (! $updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = $event->fresh(['creator']);
|
||||
|
||||
// 只发送一次开始通知,避免调度重复触发。
|
||||
if ($event && $event->started_notice_sent_at === null) {
|
||||
$creatorName = $event->creator?->username ?? '管理员';
|
||||
$content = "🎉 【{$event->title}】活动开始啦!开启人:<b>{$creatorName}</b>,时间:{$event->starts_at?->format('m-d H:i')} ~ {$event->ends_at?->format('m-d H:i')}。活动期间参与百家乐,赢的归个人,输的活动结束后可领取补偿。";
|
||||
$this->pushRoomMessage($content, '#16a34a');
|
||||
$event->update(['started_notice_sent_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据活动内是否仍有未结算下注,推进结束后的状态。
|
||||
*/
|
||||
private function transitionAfterEnd(BaccaratLossCoverEvent $event): void
|
||||
{
|
||||
if (! in_array($event->status, ['active', 'settlement_pending'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasPendingBet = BaccaratBet::query()
|
||||
->where('loss_cover_event_id', $event->id)
|
||||
->where('status', 'pending')
|
||||
->exists();
|
||||
|
||||
// 仍有活动内下注未开奖时,先进入等待结算状态。
|
||||
if ($hasPendingBet) {
|
||||
$event->update(['status' => 'settlement_pending']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 全部结算完成后,活动正式进入可领取状态。
|
||||
$compensableCount = BaccaratLossCoverRecord::query()
|
||||
->where('event_id', $event->id)
|
||||
->where('claim_status', 'pending')
|
||||
->count();
|
||||
|
||||
$event->update([
|
||||
'status' => $compensableCount > 0 ? 'claimable' : 'completed',
|
||||
'compensable_user_count' => $compensableCount,
|
||||
]);
|
||||
|
||||
if ($event->ended_notice_sent_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compensableCount > 0) {
|
||||
$button = '<button onclick="claimBaccaratLossCover('.$event->id.')" style="margin-left:8px;padding:3px 12px;background:#16a34a;color:#fff;border:none;border-radius:12px;cursor:pointer;font-size:12px;font-weight:bold;">领取补偿</button>';
|
||||
$content = "📣 【{$event->title}】活动已结束并完成结算!本次共有 <b>{$compensableCount}</b> 位玩家可领取补偿,截止时间:{$event->claim_deadline_at?->format('m-d H:i')}。{$button}";
|
||||
} else {
|
||||
$content = "📣 【{$event->title}】活动已结束!本次活动没有产生可领取补偿的记录。";
|
||||
}
|
||||
|
||||
$this->pushRoomMessage($content, '#7c3aed');
|
||||
$event->update(['ended_notice_sent_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向房间广播一条系统公告消息。
|
||||
*/
|
||||
private function pushRoomMessage(string $content, string $fontColor): void
|
||||
{
|
||||
$message = [
|
||||
'id' => $this->chatState->nextMessageId(1),
|
||||
'room_id' => 1,
|
||||
'from_user' => '系统传音',
|
||||
'to_user' => '大家',
|
||||
'content' => $content,
|
||||
'is_secret' => false,
|
||||
'font_color' => $fontColor,
|
||||
'action' => '',
|
||||
'sent_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$this->chatState->pushMessage(1, $message);
|
||||
broadcast(new MessageSent(1, $message));
|
||||
SaveMessageJob::dispatch($message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user