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'];
|
||||
|
||||
112
app/Http/Controllers/BaccaratLossCoverController.php
Normal file
112
app/Http/Controllers/BaccaratLossCoverController.php
Normal file
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Http/Requests/StoreBaccaratLossCoverEventRequest.php
Normal file
55
app/Http/Requests/StoreBaccaratLossCoverEventRequest.php
Normal file
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取押注类型中文名。
|
||||
*/
|
||||
|
||||
119
app/Models/BaccaratLossCoverEvent.php
Normal file
119
app/Models/BaccaratLossCoverEvent.php
Normal file
@@ -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 => '未知状态',
|
||||
};
|
||||
}
|
||||
}
|
||||
82
app/Models/BaccaratLossCoverRecord.php
Normal file
82
app/Models/BaccaratLossCoverRecord.php
Normal file
@@ -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 => '未知状态',
|
||||
};
|
||||
}
|
||||
}
|
||||
422
app/Services/BaccaratLossCoverService.php
Normal file
422
app/Services/BaccaratLossCoverService.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
46
database/factories/BaccaratLossCoverEventFactory.php
Normal file
46
database/factories/BaccaratLossCoverEventFactory.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动测试工厂
|
||||
*
|
||||
* 用于在测试中快速创建不同状态的百家乐买单活动记录。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BaccaratLossCoverEvent>
|
||||
*/
|
||||
class BaccaratLossCoverEventFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$startsAt = now()->subMinutes(5);
|
||||
$endsAt = now()->addMinutes(25);
|
||||
|
||||
return [
|
||||
'title' => '你玩游戏我买单',
|
||||
'description' => '测试活动说明',
|
||||
'status' => 'active',
|
||||
'starts_at' => $startsAt,
|
||||
'ends_at' => $endsAt,
|
||||
'claim_deadline_at' => $endsAt->copy()->addHours(24),
|
||||
'created_by_user_id' => User::factory(),
|
||||
'closed_by_user_id' => null,
|
||||
'started_notice_sent_at' => null,
|
||||
'ended_notice_sent_at' => null,
|
||||
'participant_count' => 0,
|
||||
'compensable_user_count' => 0,
|
||||
'total_loss_amount' => 0,
|
||||
'total_claimed_amount' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
database/factories/BaccaratLossCoverRecordFactory.php
Normal file
39
database/factories/BaccaratLossCoverRecordFactory.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动用户记录测试工厂
|
||||
*
|
||||
* 用于在测试中快速生成某个活动下的用户补偿状态。
|
||||
*/
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BaccaratLossCoverRecord>
|
||||
*/
|
||||
class BaccaratLossCoverRecordFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* 定义默认测试数据。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => BaccaratLossCoverEvent::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'total_bet_amount' => 1000,
|
||||
'total_win_payout' => 0,
|
||||
'total_loss_amount' => 500,
|
||||
'compensation_amount' => 500,
|
||||
'claim_status' => 'pending',
|
||||
'claimed_amount' => 0,
|
||||
'claimed_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动主表迁移
|
||||
*
|
||||
* 保存每一次“你玩游戏我买单”活动的完整档案,
|
||||
* 包括开启人、开始时间、结束时间、领取截止时间与最终补偿发放总额。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 baccarat_loss_cover_events 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('baccarat_loss_cover_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title', 100)->comment('活动标题');
|
||||
$table->string('description', 500)->nullable()->comment('活动说明');
|
||||
$table->enum('status', ['scheduled', 'active', 'settlement_pending', 'claimable', 'completed', 'cancelled'])
|
||||
->default('scheduled')
|
||||
->comment('活动状态');
|
||||
$table->dateTime('starts_at')->comment('活动开始时间');
|
||||
$table->dateTime('ends_at')->comment('活动结束时间');
|
||||
$table->dateTime('claim_deadline_at')->comment('补偿领取截止时间');
|
||||
$table->foreignId('created_by_user_id')
|
||||
->nullable()
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('开启人');
|
||||
$table->foreignId('closed_by_user_id')
|
||||
->nullable()
|
||||
->constrained('users')
|
||||
->nullOnDelete()
|
||||
->comment('结束人');
|
||||
$table->timestamp('started_notice_sent_at')->nullable()->comment('开始通知发送时间');
|
||||
$table->timestamp('ended_notice_sent_at')->nullable()->comment('结束通知发送时间');
|
||||
$table->unsignedInteger('participant_count')->default(0)->comment('参与人数');
|
||||
$table->unsignedInteger('compensable_user_count')->default(0)->comment('可领取补偿人数');
|
||||
$table->unsignedBigInteger('total_loss_amount')->default(0)->comment('活动内用户总输金币');
|
||||
$table->unsignedBigInteger('total_claimed_amount')->default(0)->comment('最终已补偿发放金币');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'starts_at'], 'blce_status_starts_idx');
|
||||
$table->index(['starts_at', 'ends_at'], 'blce_window_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚迁移。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('baccarat_loss_cover_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动用户记录表迁移
|
||||
*
|
||||
* 为每次活动中的每个用户维护一条聚合记录,
|
||||
* 便于前台查看参与情况、补偿金额以及领取状态。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 创建 baccarat_loss_cover_records 表。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('baccarat_loss_cover_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('event_id')
|
||||
->constrained('baccarat_loss_cover_events')
|
||||
->cascadeOnDelete()
|
||||
->comment('所属活动 ID');
|
||||
$table->foreignId('user_id')
|
||||
->constrained('users')
|
||||
->cascadeOnDelete()
|
||||
->comment('参与用户 ID');
|
||||
$table->unsignedBigInteger('total_bet_amount')->default(0)->comment('活动内累计下注金额');
|
||||
$table->unsignedBigInteger('total_win_payout')->default(0)->comment('活动内累计赢钱赔付');
|
||||
$table->unsignedBigInteger('total_loss_amount')->default(0)->comment('活动内累计输掉金币');
|
||||
$table->unsignedBigInteger('compensation_amount')->default(0)->comment('可领取补偿总额');
|
||||
$table->enum('claim_status', ['not_eligible', 'pending', 'claimed', 'expired'])
|
||||
->default('not_eligible')
|
||||
->comment('领取状态');
|
||||
$table->unsignedBigInteger('claimed_amount')->default(0)->comment('已领取补偿金额');
|
||||
$table->timestamp('claimed_at')->nullable()->comment('领取时间');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['event_id', 'user_id'], 'uq_blcr_event_user');
|
||||
$table->index(['user_id', 'claim_status'], 'blcr_user_claim_status_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚迁移。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('baccarat_loss_cover_records');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:为百家乐下注表补充买单活动关联字段
|
||||
*
|
||||
* 用于标记某一笔下注是否参与了指定的买单活动。
|
||||
*/
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 为 baccarat_bets 表新增活动关联字段。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('baccarat_bets', function (Blueprint $table) {
|
||||
$table->foreignId('loss_cover_event_id')
|
||||
->nullable()
|
||||
->after('user_id')
|
||||
->constrained('baccarat_loss_cover_events')
|
||||
->nullOnDelete()
|
||||
->comment('参与的买单活动 ID');
|
||||
|
||||
$table->index(['loss_cover_event_id', 'status'], 'bb_loss_cover_status_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚新增字段。
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('baccarat_bets', function (Blueprint $table) {
|
||||
$table->dropIndex('bb_loss_cover_status_idx');
|
||||
$table->dropConstrainedForeignId('loss_cover_event_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -196,6 +196,7 @@
|
||||
@include('chat.partials.games.red-packet-panel')
|
||||
@include('chat.partials.games.fishing-panel')
|
||||
@include('chat.partials.games.game-hall')
|
||||
@include('chat.partials.games.baccarat-loss-cover-panel')
|
||||
@include('chat.partials.games.gomoku-panel')
|
||||
@include('chat.partials.games.earn-panel')
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
{{--
|
||||
文件功能:百家乐买单活动前台弹窗
|
||||
|
||||
用于在娱乐大厅中查看当前买单活动、历史活动、
|
||||
以及当前用户在每次活动中的补偿领取状态。
|
||||
--}}
|
||||
|
||||
<div id="baccarat-loss-cover-modal"
|
||||
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:9997; justify-content:center; align-items:center;">
|
||||
<div
|
||||
style="width:720px; max-width:96vw; max-height:88vh; border-radius:8px; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.3); background:#fff; display:flex; flex-direction:column;">
|
||||
<div
|
||||
style="background:linear-gradient(135deg,#15803d,#22c55e); color:#fff; padding:12px 16px; display:flex; align-items:center; justify-content:space-between;">
|
||||
<div>
|
||||
<div style="font-size:15px; font-weight:bold;">🎁 你玩游戏我买单</div>
|
||||
<div style="font-size:11px; color:rgba(255,255,255,.82); margin-top:2px;">查看活动历史、个人记录与补偿领取状态</div>
|
||||
</div>
|
||||
<span onclick="closeBaccaratLossCoverModal()"
|
||||
style="cursor:pointer; font-size:18px; opacity:.85;">×</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; padding:10px 14px; background:#f0fdf4; border-bottom:1px solid #dcfce7;">
|
||||
<button type="button" id="blc-tab-overview"
|
||||
onclick="switchBaccaratLossCoverTab('overview')"
|
||||
style="padding:6px 14px; border:none; border-radius:999px; background:#15803d; color:#fff; font-size:12px; font-weight:bold; cursor:pointer;">
|
||||
当前活动
|
||||
</button>
|
||||
<button type="button" id="blc-tab-history"
|
||||
onclick="switchBaccaratLossCoverTab('history')"
|
||||
style="padding:6px 14px; border:none; border-radius:999px; background:#dcfce7; color:#166534; font-size:12px; font-weight:bold; cursor:pointer;">
|
||||
活动历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; overflow-y:auto; background:#f6fff8; padding:14px;">
|
||||
<div id="blc-overview-pane">
|
||||
<div id="blc-current-event"
|
||||
style="background:#fff; border:1px solid #dcfce7; border-radius:10px; padding:18px; color:#166534; text-align:center;">
|
||||
加载中…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blc-history-pane" style="display:none;">
|
||||
<div id="blc-history-list"
|
||||
style="display:flex; flex-direction:column; gap:10px; color:#166534;">
|
||||
加载中…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:10px 16px; background:#fff; border-top:1px solid #dcfce7; text-align:center;">
|
||||
<button type="button" onclick="closeBaccaratLossCoverModal()"
|
||||
style="padding:8px 28px; border:none; border-radius:8px; background:#dcfce7; color:#166534; font-size:12px; font-weight:bold; cursor:pointer;">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const SUMMARY_URL = '{{ route('baccarat-loss-cover.summary') }}';
|
||||
const HISTORY_URL = '{{ route('baccarat-loss-cover.history') }}';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text ?? '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function syncUserGold(amount) {
|
||||
if (typeof amount !== 'number' || !window.chatContext) return;
|
||||
window.chatContext.userJjb = Number(window.chatContext.userJjb || 0) + amount;
|
||||
window.chatContext.myGold = Number(window.chatContext.myGold || 0) + amount;
|
||||
|
||||
const hallGold = document.getElementById('game-hall-jjb');
|
||||
if (hallGold) {
|
||||
hallGold.textContent = Number(window.chatContext.userJjb || 0).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentEvent(event) {
|
||||
const container = document.getElementById('blc-current-event');
|
||||
if (!event) {
|
||||
container.innerHTML = `
|
||||
<div style="font-size:30px; margin-bottom:10px;">📭</div>
|
||||
<div style="font-size:16px; font-weight:bold; color:#166534;">当前暂无进行中的买单活动</div>
|
||||
<div style="font-size:12px; color:#4b5563; margin-top:8px;">可以在输入框上方的管理员按钮中创建新活动,也可以在这里查看历史记录。</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const myRecord = event.my_record;
|
||||
const claimButton = event.status === 'claimable' && myRecord?.claim_status === 'pending'
|
||||
? `<button type="button" onclick="claimBaccaratLossCover(${event.id})" style="padding:8px 18px;border:none;border-radius:999px;background:#16a34a;color:#fff;font-size:12px;font-weight:bold;cursor:pointer;">领取补偿</button>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; text-align:left;">
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:18px; font-weight:900; color:#166534;">${escapeHtml(event.title)}</div>
|
||||
<div style="font-size:12px; color:#4b5563; margin-top:6px; line-height:1.7;">${escapeHtml(event.description || '活动期间参与百家乐,输掉的金币可在活动结束后领取补偿。')}</div>
|
||||
</div>
|
||||
<span style="padding:4px 10px; border-radius:999px; background:#dcfce7; color:#166534; font-size:12px; font-weight:bold;">${escapeHtml(event.status_label)}</span>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:10px; margin-top:16px; text-align:left;">
|
||||
<div style="background:#f0fdf4; border-radius:8px; padding:12px;">
|
||||
<div style="font-size:11px; color:#6b7280;">开启人</div>
|
||||
<div style="font-size:14px; font-weight:bold; color:#166534; margin-top:3px;">${escapeHtml(event.creator_username)}</div>
|
||||
</div>
|
||||
<div style="background:#f0fdf4; border-radius:8px; padding:12px;">
|
||||
<div style="font-size:11px; color:#6b7280;">活动时间</div>
|
||||
<div style="font-size:13px; font-weight:bold; color:#166534; margin-top:3px;">${formatTime(event.starts_at)} - ${formatTime(event.ends_at)}</div>
|
||||
</div>
|
||||
<div style="background:#f0fdf4; border-radius:8px; padding:12px;">
|
||||
<div style="font-size:11px; color:#6b7280;">最终已发补偿</div>
|
||||
<div style="font-size:14px; font-weight:bold; color:#166534; margin-top:3px;">${Number(event.total_claimed_amount || 0).toLocaleString()} 金币</div>
|
||||
</div>
|
||||
<div style="background:#f0fdf4; border-radius:8px; padding:12px;">
|
||||
<div style="font-size:11px; color:#6b7280;">我的状态</div>
|
||||
<div style="font-size:14px; font-weight:bold; color:#166534; margin-top:3px;">${escapeHtml(myRecord?.claim_status_label || '未参与')}</div>
|
||||
</div>
|
||||
</div>
|
||||
${myRecord ? `
|
||||
<div style="margin-top:16px; background:#fffbeb; border:1px solid #fde68a; border-radius:10px; padding:14px; text-align:left;">
|
||||
<div style="font-size:13px; font-weight:bold; color:#a16207; margin-bottom:8px;">我的活动记录</div>
|
||||
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:8px; font-size:12px; color:#4b5563;">
|
||||
<div>累计下注:<b style="color:#166534;">${Number(myRecord.total_bet_amount || 0).toLocaleString()}</b></div>
|
||||
<div>累计输掉:<b style="color:#b91c1c;">${Number(myRecord.total_loss_amount || 0).toLocaleString()}</b></div>
|
||||
<div>可领补偿:<b style="color:#166534;">${Number(myRecord.compensation_amount || 0).toLocaleString()}</b></div>
|
||||
<div>已领补偿:<b style="color:#166534;">${Number(myRecord.claimed_amount || 0).toLocaleString()}</b></div>
|
||||
</div>
|
||||
${claimButton ? `<div style="margin-top:12px;">${claimButton}</div>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHistory(events) {
|
||||
const container = document.getElementById('blc-history-list');
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div style="text-align:center;color:#6b7280;padding:24px 0;">暂无活动记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = events.map(event => {
|
||||
const myRecord = event.my_record;
|
||||
const claimButton = event.status === 'claimable' && myRecord?.claim_status === 'pending'
|
||||
? `<button type="button" onclick="claimBaccaratLossCover(${event.id})" style="padding:6px 14px;border:none;border-radius:999px;background:#16a34a;color:#fff;font-size:12px;font-weight:bold;cursor:pointer;">领取补偿</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div style="background:#fff; border:1px solid #dcfce7; border-left:4px solid #16a34a; border-radius:8px; padding:14px;">
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start;">
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:15px; font-weight:bold; color:#166534;">${escapeHtml(event.title)}</div>
|
||||
<div style="font-size:12px; color:#4b5563; margin-top:4px;">开启人:${escapeHtml(event.creator_username)} | ${formatTime(event.starts_at)} - ${formatTime(event.ends_at)}</div>
|
||||
</div>
|
||||
<span style="padding:4px 10px; border-radius:999px; background:#f0fdf4; color:#166534; font-size:12px; font-weight:bold;">${escapeHtml(event.status_label)}</span>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-top:12px; font-size:12px; color:#4b5563;">
|
||||
<div>最终补偿发放:<b style="color:#166534;">${Number(event.total_claimed_amount || 0).toLocaleString()}</b></div>
|
||||
<div>本次总输金币:<b style="color:#b91c1c;">${Number(event.total_loss_amount || 0).toLocaleString()}</b></div>
|
||||
<div>我的状态:<b style="color:#166534;">${escapeHtml(myRecord?.claim_status_label || '未参与')}</b></div>
|
||||
<div>我的可领:<b style="color:#166534;">${Number(myRecord?.compensation_amount || 0).toLocaleString()}</b></div>
|
||||
</div>
|
||||
${myRecord ? `<div style="margin-top:8px; font-size:12px; color:#6b7280;">累计下注 ${Number(myRecord.total_bet_amount || 0).toLocaleString()} | 累计输掉 ${Number(myRecord.total_loss_amount || 0).toLocaleString()} | 已领 ${Number(myRecord.claimed_amount || 0).toLocaleString()}</div>` : ''}
|
||||
${claimButton ? `<div style="margin-top:10px;">${claimButton}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const current = document.getElementById('blc-current-event');
|
||||
current.innerHTML = '加载中…';
|
||||
|
||||
try {
|
||||
const response = await fetch(SUMMARY_URL, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
renderCurrentEvent(data.event || null);
|
||||
} catch (error) {
|
||||
current.innerHTML = '<div style="color:#dc2626;">活动摘要加载失败,请稍后重试。</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const list = document.getElementById('blc-history-list');
|
||||
list.innerHTML = '加载中…';
|
||||
|
||||
try {
|
||||
const response = await fetch(HISTORY_URL, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
renderHistory(data.events || []);
|
||||
} catch (error) {
|
||||
list.innerHTML = '<div style="color:#dc2626;">活动历史加载失败,请稍后重试。</div>';
|
||||
}
|
||||
}
|
||||
|
||||
window.switchBaccaratLossCoverTab = function(tab) {
|
||||
const overview = document.getElementById('blc-overview-pane');
|
||||
const history = document.getElementById('blc-history-pane');
|
||||
const overviewBtn = document.getElementById('blc-tab-overview');
|
||||
const historyBtn = document.getElementById('blc-tab-history');
|
||||
|
||||
if (tab === 'history') {
|
||||
overview.style.display = 'none';
|
||||
history.style.display = 'block';
|
||||
overviewBtn.style.background = '#dcfce7';
|
||||
overviewBtn.style.color = '#166534';
|
||||
historyBtn.style.background = '#15803d';
|
||||
historyBtn.style.color = '#fff';
|
||||
return;
|
||||
}
|
||||
|
||||
overview.style.display = 'block';
|
||||
history.style.display = 'none';
|
||||
overviewBtn.style.background = '#15803d';
|
||||
overviewBtn.style.color = '#fff';
|
||||
historyBtn.style.background = '#dcfce7';
|
||||
historyBtn.style.color = '#166534';
|
||||
};
|
||||
|
||||
window.openBaccaratLossCoverModal = async function(tab = 'overview') {
|
||||
document.getElementById('baccarat-loss-cover-modal').style.display = 'flex';
|
||||
window.switchBaccaratLossCoverTab(tab);
|
||||
await Promise.all([loadSummary(), loadHistory()]);
|
||||
};
|
||||
|
||||
window.closeBaccaratLossCoverModal = function() {
|
||||
document.getElementById('baccarat-loss-cover-modal').style.display = 'none';
|
||||
};
|
||||
|
||||
window.claimBaccaratLossCover = async function(eventId) {
|
||||
try {
|
||||
const response = await fetch(`/baccarat-loss-cover/${eventId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
syncUserGold(Number(data.amount || 0));
|
||||
if (window.chatDialog?.alert) {
|
||||
await window.chatDialog.alert(data.message || '补偿领取成功', '系统通知', '#16a34a');
|
||||
}
|
||||
await Promise.all([loadSummary(), loadHistory()]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.chatDialog?.alert) {
|
||||
await window.chatDialog.alert(data.message || '领取失败', '提示', '#f59e0b');
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.chatDialog?.alert) {
|
||||
await window.chatDialog.alert('领取失败,请稍后重试。', '提示', '#dc2626');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('baccarat-loss-cover-modal').addEventListener('click', function(event) {
|
||||
if (event.target === this) {
|
||||
closeBaccaratLossCoverModal();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -127,6 +127,63 @@
|
||||
},
|
||||
btnLabel: (data) => data?.round?.status === 'betting' ? '🎲 立即下注' : '📊 查看详情',
|
||||
},
|
||||
{
|
||||
id: 'baccarat_loss_cover',
|
||||
name: '🎁 买单活动',
|
||||
desc: '查看“你玩游戏我买单”活动,补偿领取和个人历史记录',
|
||||
accentColor: '#16a34a',
|
||||
fetchUrl: '/baccarat-loss-cover/summary',
|
||||
openFn: () => {
|
||||
closeGameHall();
|
||||
if (typeof openBaccaratLossCoverModal === 'function') {
|
||||
openBaccaratLossCoverModal('overview');
|
||||
}
|
||||
},
|
||||
renderStatus: (data) => {
|
||||
const event = data?.event;
|
||||
if (!event) {
|
||||
return {
|
||||
badge: '📭 暂无活动',
|
||||
badgeStyle: 'background:#e8f0f8; color:#336699; border:1px solid #b8d0e8',
|
||||
detail: '当前没有进行中或待领取的买单活动'
|
||||
};
|
||||
}
|
||||
|
||||
const myStatus = event.my_record?.claim_status_label || '未参与';
|
||||
const total = Number(event.total_claimed_amount || 0).toLocaleString();
|
||||
|
||||
if (event.status === 'active') {
|
||||
return {
|
||||
badge: '🟢 进行中',
|
||||
badgeStyle: 'background:#d1fae5; color:#065f46; border:1px solid #6ee7b7',
|
||||
detail: `我的状态:${myStatus} | 开启人:${event.creator_username}`
|
||||
};
|
||||
}
|
||||
|
||||
if (event.status === 'settlement_pending') {
|
||||
return {
|
||||
badge: '⏳ 结算中',
|
||||
badgeStyle: 'background:#fef3c7; color:#92400e; border:1px solid #fcd34d',
|
||||
detail: `活动已结束,等待最后几局结算 | 我的状态:${myStatus}`
|
||||
};
|
||||
}
|
||||
|
||||
if (event.status === 'claimable') {
|
||||
return {
|
||||
badge: '💰 可领取',
|
||||
badgeStyle: 'background:#dcfce7; color:#166534; border:1px solid #86efac',
|
||||
detail: `我的状态:${myStatus} | 已发补偿 ${total} 金币`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
badge: '🕒 即将开始',
|
||||
badgeStyle: 'background:#ede9fe; color:#5b21b6; border:1px solid #c4b5fd',
|
||||
detail: `开启人:${event.creator_username} | 我的状态:${myStatus}`
|
||||
};
|
||||
},
|
||||
btnLabel: (data) => data?.event?.status === 'claimable' ? '💰 查看并领取' : '📜 查看活动',
|
||||
},
|
||||
{
|
||||
id: 'slot_machine',
|
||||
name: '🎰 老虎机',
|
||||
|
||||
@@ -122,6 +122,9 @@
|
||||
<button type="button" id="red-packet-btn" onclick="sendRedPacket()"
|
||||
style="font-size: 11px; padding: 1px 6px; background: linear-gradient(135deg, #dc2626, #d97706); color: #fff; border: none; border-radius: 2px; cursor: pointer; font-weight: bold;">🧧
|
||||
礼包</button>
|
||||
<button type="button" onclick="openAdminBaccaratLossCoverModal()"
|
||||
style="font-size: 11px; padding: 1px 6px; background: linear-gradient(135deg, #15803d, #22c55e); color: #fff; border: none; border-radius: 2px; cursor: pointer; font-weight: bold;">🎁
|
||||
买单活动</button>
|
||||
{{-- 全屏特效按钮组(仅管理员可见) --}}
|
||||
<button type="button" onclick="triggerEffect('fireworks')" title="全屏烟花"
|
||||
style="font-size: 11px; padding: 1px 6px; background: #ea580c; color: #fff; border: none; border-radius: 2px; cursor: pointer;">🎆
|
||||
@@ -150,3 +153,205 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if ($user->user_level >= (int) \App\Models\Sysparam::getValue('superlevel', '100'))
|
||||
<div id="baccarat-loss-cover-admin-modal"
|
||||
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:10010; justify-content:center; align-items:center;">
|
||||
<div
|
||||
style="width:560px; max-width:96vw; max-height:88vh; overflow:hidden; border-radius:8px; background:#fff; box-shadow:0 8px 32px rgba(0,0,0,.3); display:flex; flex-direction:column;">
|
||||
<div
|
||||
style="background:linear-gradient(135deg,#15803d,#22c55e); color:#fff; padding:12px 16px; display:flex; justify-content:space-between; align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:14px; font-weight:bold;">🎁 买单活动设置</div>
|
||||
<div style="font-size:11px; color:rgba(255,255,255,.85); margin-top:2px;">创建百家乐“你玩游戏我买单”活动,并查看当前状态</div>
|
||||
</div>
|
||||
<span onclick="closeAdminBaccaratLossCoverModal()"
|
||||
style="cursor:pointer; font-size:18px; opacity:.85;">×</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; overflow-y:auto; padding:16px; background:#f6fff8;">
|
||||
<div id="blc-admin-current"
|
||||
style="background:#fff; border:1px solid #dcfce7; border-radius:10px; padding:14px; color:#166534; margin-bottom:14px;">
|
||||
正在加载当前活动…
|
||||
</div>
|
||||
|
||||
<form id="baccarat-loss-cover-admin-form" onsubmit="submitBaccaratLossCoverEvent(event)"
|
||||
style="background:#fff; border:1px solid #dcfce7; border-radius:10px; padding:14px;">
|
||||
<div style="font-size:13px; font-weight:bold; color:#166534; margin-bottom:12px;">新建活动</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; color:#4b5563; margin-bottom:4px;">活动标题</label>
|
||||
<input type="text" id="blc-admin-title" maxlength="100" value="你玩游戏我买单"
|
||||
style="width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #bbf7d0; border-radius:8px; font-size:12px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; color:#4b5563; margin-bottom:4px;">活动说明</label>
|
||||
<textarea id="blc-admin-description" rows="3"
|
||||
style="width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #bbf7d0; border-radius:8px; font-size:12px; resize:vertical;">活动期间参与百家乐,赢得归个人,输掉的金币可在活动结束后领取补偿。</textarea>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; color:#4b5563; margin-bottom:4px;">开始时间</label>
|
||||
<input type="datetime-local" id="blc-admin-starts-at"
|
||||
style="width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #bbf7d0; border-radius:8px; font-size:12px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; color:#4b5563; margin-bottom:4px;">结束时间</label>
|
||||
<input type="datetime-local" id="blc-admin-ends-at"
|
||||
style="width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #bbf7d0; border-radius:8px; font-size:12px;">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:12px; color:#4b5563; margin-bottom:4px;">领取截止时间</label>
|
||||
<input type="datetime-local" id="blc-admin-claim-deadline-at"
|
||||
style="width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #bbf7d0; border-radius:8px; font-size:12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">
|
||||
<button type="button" onclick="closeAdminBaccaratLossCoverModal()"
|
||||
style="padding:8px 16px; border:none; border-radius:8px; background:#dcfce7; color:#166534; font-size:12px; font-weight:bold; cursor:pointer;">
|
||||
关闭
|
||||
</button>
|
||||
<button type="submit"
|
||||
style="padding:8px 16px; border:none; border-radius:8px; background:#16a34a; color:#fff; font-size:12px; font-weight:bold; cursor:pointer;">
|
||||
创建活动
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const SUMMARY_URL = '{{ route('baccarat-loss-cover.summary') }}';
|
||||
const STORE_URL = '{{ route('command.baccarat_loss_cover.store') }}';
|
||||
|
||||
function formatDateInput(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`;
|
||||
}
|
||||
|
||||
async function loadAdminCurrentLossCoverEvent() {
|
||||
const box = document.getElementById('blc-admin-current');
|
||||
box.innerHTML = '正在加载当前活动…';
|
||||
|
||||
try {
|
||||
const response = await fetch(SUMMARY_URL, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
const event = data.event;
|
||||
|
||||
if (!event) {
|
||||
box.innerHTML = '<div style="font-size:12px; color:#4b5563;">当前没有进行中、待开始或待领取的买单活动。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const closeButton = ['scheduled', 'active', 'settlement_pending'].includes(event.status) ?
|
||||
`<button type="button" onclick="closeCurrentBaccaratLossCoverEvent(${event.id})" style="margin-top:10px; padding:7px 14px; border:none; border-radius:999px; background:#dc2626; color:#fff; font-size:12px; font-weight:bold; cursor:pointer;">立即结束</button>` :
|
||||
'';
|
||||
|
||||
box.innerHTML = `
|
||||
<div style="display:flex; justify-content:space-between; gap:10px; align-items:flex-start;">
|
||||
<div style="flex:1;">
|
||||
<div style="font-size:15px; font-weight:bold; color:#166534;">${event.title}</div>
|
||||
<div style="font-size:12px; color:#4b5563; margin-top:4px;">开启人:${event.creator_username}</div>
|
||||
</div>
|
||||
<span style="padding:4px 10px; border-radius:999px; background:#dcfce7; color:#166534; font-size:12px; font-weight:bold;">${event.status_label}</span>
|
||||
</div>
|
||||
<div style="margin-top:10px; font-size:12px; color:#4b5563; line-height:1.7;">
|
||||
活动时间:${new Date(event.starts_at).toLocaleString('zh-CN')} - ${new Date(event.ends_at).toLocaleString('zh-CN')}<br>
|
||||
最终已发补偿:${Number(event.total_claimed_amount || 0).toLocaleString()} 金币
|
||||
</div>
|
||||
${closeButton}
|
||||
`;
|
||||
} catch (error) {
|
||||
box.innerHTML = '<div style="font-size:12px; color:#dc2626;">当前活动加载失败,请稍后再试。</div>';
|
||||
}
|
||||
}
|
||||
|
||||
window.openAdminBaccaratLossCoverModal = async function() {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 30 * 60 * 1000);
|
||||
const claimDeadline = new Date(end.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
document.getElementById('blc-admin-starts-at').value = formatDateInput(now);
|
||||
document.getElementById('blc-admin-ends-at').value = formatDateInput(end);
|
||||
document.getElementById('blc-admin-claim-deadline-at').value = formatDateInput(claimDeadline);
|
||||
document.getElementById('baccarat-loss-cover-admin-modal').style.display = 'flex';
|
||||
await loadAdminCurrentLossCoverEvent();
|
||||
};
|
||||
|
||||
window.closeAdminBaccaratLossCoverModal = function() {
|
||||
document.getElementById('baccarat-loss-cover-admin-modal').style.display = 'none';
|
||||
};
|
||||
|
||||
window.submitBaccaratLossCoverEvent = async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const payload = {
|
||||
title: document.getElementById('blc-admin-title').value,
|
||||
description: document.getElementById('blc-admin-description').value,
|
||||
starts_at: document.getElementById('blc-admin-starts-at').value,
|
||||
ends_at: document.getElementById('blc-admin-ends-at').value,
|
||||
claim_deadline_at: document.getElementById('blc-admin-claim-deadline-at').value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(STORE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
await window.chatDialog?.alert(data.message || '活动创建成功', '系统通知', '#16a34a');
|
||||
await loadAdminCurrentLossCoverEvent();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.chatDialog?.alert(data.message || '活动创建失败', '提示', '#f59e0b');
|
||||
} catch (error) {
|
||||
await window.chatDialog?.alert('活动创建失败,请稍后重试。', '提示', '#dc2626');
|
||||
}
|
||||
};
|
||||
|
||||
window.closeCurrentBaccaratLossCoverEvent = async function(eventId) {
|
||||
try {
|
||||
const response = await fetch(`/command/baccarat-loss-cover/${eventId}/close`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
await window.chatDialog?.alert(data.message || '活动状态已更新', '系统通知', '#16a34a');
|
||||
await loadAdminCurrentLossCoverEvent();
|
||||
} catch (error) {
|
||||
await window.chatDialog?.alert('活动关闭失败,请稍后重试。', '提示', '#dc2626');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('baccarat-loss-cover-admin-modal').addEventListener('click', function(event) {
|
||||
if (event.target === this) {
|
||||
closeAdminBaccaratLossCoverModal();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@@ -45,6 +45,11 @@ Schedule::call(function () {
|
||||
->each(fn ($e) => \App\Jobs\TriggerHolidayEventJob::dispatch($e));
|
||||
})->everyMinute()->name('holiday-events:trigger')->withoutOverlapping();
|
||||
|
||||
// 每分钟:推进百家乐买单活动状态(开始 / 等待结算 / 开放领取 / 过期收尾)
|
||||
Schedule::call(function () {
|
||||
app(\App\Services\BaccaratLossCoverService::class)->tick();
|
||||
})->everyMinute()->name('baccarat-loss-cover:tick')->withoutOverlapping();
|
||||
|
||||
// ──────────── 百家乐定时任务 ─────────────────────────────────────
|
||||
|
||||
// 每分钟:检查是否应开新一局(游戏开启 + 无正在进行的局)
|
||||
|
||||
@@ -142,6 +142,13 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
Route::get('/history', [\App\Http\Controllers\BaccaratController::class, 'history'])->name('history');
|
||||
});
|
||||
|
||||
// ── 百家乐买单活动(前台)───────────────────────────────────────
|
||||
Route::prefix('baccarat-loss-cover')->name('baccarat-loss-cover.')->group(function () {
|
||||
Route::get('/summary', [\App\Http\Controllers\BaccaratLossCoverController::class, 'summary'])->name('summary');
|
||||
Route::get('/history', [\App\Http\Controllers\BaccaratLossCoverController::class, 'history'])->name('history');
|
||||
Route::post('/{event}/claim', [\App\Http\Controllers\BaccaratLossCoverController::class, 'claim'])->name('claim');
|
||||
});
|
||||
|
||||
// ── 老虎机(前台)────────────────────────────────────────────────
|
||||
Route::prefix('slot')->name('slot.')->group(function () {
|
||||
// 获取配置及今日剩余次数
|
||||
@@ -295,6 +302,10 @@ Route::middleware(['chat.auth'])->group(function () {
|
||||
Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce');
|
||||
Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen');
|
||||
Route::post('/command/effect', [AdminCommandController::class, 'effect'])->name('command.effect');
|
||||
Route::middleware('chat.level:super')->group(function () {
|
||||
Route::post('/command/baccarat-loss-cover', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'store'])->name('command.baccarat_loss_cover.store');
|
||||
Route::post('/command/baccarat-loss-cover/{event}/close', [\App\Http\Controllers\Admin\BaccaratLossCoverEventController::class, 'close'])->name('command.baccarat_loss_cover.close');
|
||||
});
|
||||
|
||||
// ---- 礼包红包(superlevel 发包 / 所有登录用户可抢)----
|
||||
Route::post('/command/red-packet/send', [\App\Http\Controllers\RedPacketController::class, 'send'])->name('command.red_packet.send');
|
||||
|
||||
203
tests/Feature/BaccaratLossCoverControllerTest.php
Normal file
203
tests/Feature/BaccaratLossCoverControllerTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:百家乐买单活动功能测试
|
||||
*
|
||||
* 覆盖活动创建、下注关联、补偿领取与历史记录接口,
|
||||
* 确保活动统计与金币流水都能正确落库。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\CurrencySource;
|
||||
use App\Models\BaccaratLossCoverEvent;
|
||||
use App\Models\BaccaratLossCoverRecord;
|
||||
use App\Models\BaccaratRound;
|
||||
use App\Models\GameConfig;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BaccaratLossCoverControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 初始化百家乐基础配置。
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
GameConfig::updateOrCreate(
|
||||
['game_key' => 'baccarat'],
|
||||
[
|
||||
'name' => 'Baccarat',
|
||||
'icon' => 'baccarat',
|
||||
'description' => 'Baccarat Game',
|
||||
'enabled' => true,
|
||||
'params' => [
|
||||
'min_bet' => 100,
|
||||
'max_bet' => 50000,
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 superlevel 管理员可以创建百家乐买单活动。
|
||||
*/
|
||||
public function test_superlevel_admin_can_create_baccarat_loss_cover_event(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$admin = User::factory()->create(['user_level' => 100]);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(route('command.baccarat_loss_cover.store'), [
|
||||
'title' => '你玩游戏我买单',
|
||||
'description' => '测试活动',
|
||||
'starts_at' => now()->addMinutes(5)->toDateTimeString(),
|
||||
'ends_at' => now()->addMinutes(35)->toDateTimeString(),
|
||||
'claim_deadline_at' => now()->addDay()->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJson(['ok' => true]);
|
||||
|
||||
$this->assertDatabaseHas('baccarat_loss_cover_events', [
|
||||
'title' => '你玩游戏我买单',
|
||||
'created_by_user_id' => $admin->id,
|
||||
'status' => 'scheduled',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证活动进行中下注会挂到活动并写入用户聚合记录。
|
||||
*/
|
||||
public function test_bet_during_active_event_is_tracked_in_loss_cover_record(): void
|
||||
{
|
||||
Event::fake();
|
||||
|
||||
$user = User::factory()->create(['jjb' => 500]);
|
||||
$event = BaccaratLossCoverEvent::factory()->create([
|
||||
'status' => 'active',
|
||||
'starts_at' => now()->subMinutes(2),
|
||||
'ends_at' => now()->addMinutes(20),
|
||||
]);
|
||||
|
||||
$round = BaccaratRound::forceCreate([
|
||||
'status' => 'betting',
|
||||
'bet_opens_at' => now(),
|
||||
'bet_closes_at' => now()->addMinutes(1),
|
||||
'total_bet_big' => 0,
|
||||
'total_bet_small' => 0,
|
||||
'total_bet_triple' => 0,
|
||||
'bet_count' => 0,
|
||||
'bet_count_big' => 0,
|
||||
'bet_count_small' => 0,
|
||||
'bet_count_triple' => 0,
|
||||
'total_payout' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('baccarat.bet'), [
|
||||
'round_id' => $round->id,
|
||||
'bet_type' => 'big',
|
||||
'amount' => 100,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJson(['ok' => true]);
|
||||
|
||||
$this->assertDatabaseHas('baccarat_bets', [
|
||||
'round_id' => $round->id,
|
||||
'user_id' => $user->id,
|
||||
'loss_cover_event_id' => $event->id,
|
||||
'amount' => 100,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('baccarat_loss_cover_records', [
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'total_bet_amount' => 100,
|
||||
'claim_status' => 'not_eligible',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('baccarat_loss_cover_events', [
|
||||
'id' => $event->id,
|
||||
'participant_count' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户领取补偿后会增加金币并写入金币流水。
|
||||
*/
|
||||
public function test_user_can_claim_baccarat_loss_cover_and_currency_log_is_written(): void
|
||||
{
|
||||
$user = User::factory()->create(['jjb' => 200]);
|
||||
$event = BaccaratLossCoverEvent::factory()->create([
|
||||
'status' => 'claimable',
|
||||
'claim_deadline_at' => now()->addHours(12),
|
||||
'total_loss_amount' => 300,
|
||||
]);
|
||||
|
||||
BaccaratLossCoverRecord::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'total_bet_amount' => 600,
|
||||
'total_loss_amount' => 300,
|
||||
'compensation_amount' => 300,
|
||||
'claim_status' => 'pending',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson(route('baccarat-loss-cover.claim', $event));
|
||||
|
||||
$response->assertOk()->assertJson([
|
||||
'ok' => true,
|
||||
'amount' => 300,
|
||||
]);
|
||||
|
||||
$this->assertSame(500, (int) $user->fresh()->jjb);
|
||||
|
||||
$this->assertDatabaseHas('baccarat_loss_cover_records', [
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'claim_status' => 'claimed',
|
||||
'claimed_amount' => 300,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('user_currency_logs', [
|
||||
'user_id' => $user->id,
|
||||
'currency' => 'gold',
|
||||
'amount' => 300,
|
||||
'source' => CurrencySource::BACCARAT_LOSS_COVER_CLAIM->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证历史接口会返回当前用户在活动中的领取状态。
|
||||
*/
|
||||
public function test_history_endpoint_contains_my_claim_status(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$event = BaccaratLossCoverEvent::factory()->create([
|
||||
'status' => 'completed',
|
||||
'total_claimed_amount' => 400,
|
||||
]);
|
||||
|
||||
BaccaratLossCoverRecord::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'claim_status' => 'claimed',
|
||||
'claimed_amount' => 400,
|
||||
'compensation_amount' => 400,
|
||||
'claimed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson(route('baccarat-loss-cover.history'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('events.0.id', $event->id);
|
||||
$response->assertJsonPath('events.0.my_record.claim_status', 'claimed');
|
||||
$response->assertJsonPath('events.0.my_record.claimed_amount', 400);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user