新增百家乐游戏:①数据库表+模型 ②OpenBaccaratRoundJob开局(广播+公屏) ③CloseBaccaratRoundJob结算(摇骰+赔付+CAS防并发) ④BaccaratController下注接口 ⑤前端弹窗(倒计时/骰子动画/历史趋势) ⑥调度器每分钟检查开局 ⑦GameConfig管控开关

This commit is contained in:
2026-03-01 20:25:09 +08:00
parent 8a74bfd639
commit ff28775635
15 changed files with 1424 additions and 0 deletions
+8
View File
@@ -72,6 +72,12 @@ enum CurrencySource: string
/** 节日福利红包(管理员设置的定时金币福利) */
case HOLIDAY_BONUS = 'holiday_bonus';
/** 百家乐下注消耗(扣除金币) */
case BACCARAT_BET = 'baccarat_bet';
/** 百家乐中奖赔付(收入金币,含本金返还) */
case BACCARAT_WIN = 'baccarat_win';
/**
* 返回该来源的中文名称,用于后台统计展示。
*/
@@ -95,6 +101,8 @@ enum CurrencySource: string
self::WEDDING_ENV_RECV => '领取婚礼红包',
self::FORCED_DIVORCE_TRANSFER => '强制离婚财产转移',
self::HOLIDAY_BONUS => '节日福利',
self::BACCARAT_BET => '百家乐下注',
self::BACCARAT_WIN => '百家乐赢钱',
};
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* 文件功能:百家乐开局广播事件
*
* 新局开始时广播给房间所有用户,携带局次 ID 和下注截止时间,
* 前端收到后展示倒计时下注面板。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\BaccaratRound;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BaccaratRoundOpened implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param BaccaratRound $round 本局信息
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
/**
* 广播至房间公共频道。
*
* @return array<Channel>
*/
public function broadcastOn(): array
{
return [new Channel('room.1')];
}
/**
* 广播事件名(前端监听 .baccarat.opened)。
*/
public function broadcastAs(): string
{
return 'baccarat.opened';
}
/**
* 广播数据。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'round_id' => $this->round->id,
'bet_opens_at' => $this->round->bet_opens_at->toIso8601String(),
'bet_closes_at' => $this->round->bet_closes_at->toIso8601String(),
'bet_seconds' => (int) now()->diffInSeconds($this->round->bet_closes_at),
];
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:百家乐结算广播事件
*
* 开奖后广播骰子结果和获奖类型,前端播放骰子动画,
* 并显示用户是否中奖及赔付金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Events;
use App\Models\BaccaratRound;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BaccaratRoundSettled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* @param BaccaratRound $round 已结算的局次
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
/**
* 广播至房间公共频道。
*
* @return array<Channel>
*/
public function broadcastOn(): array
{
return [new Channel('room.1')];
}
/**
* 广播事件名(前端监听 .baccarat.settled)。
*/
public function broadcastAs(): string
{
return 'baccarat.settled';
}
/**
* 广播数据:骰子点数 + 开奖结果 + 统计。
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'round_id' => $this->round->id,
'dice' => [$this->round->dice1, $this->round->dice2, $this->round->dice3],
'total_points' => $this->round->total_points,
'result' => $this->round->result,
'result_label' => $this->round->resultLabel(),
'total_payout' => $this->round->total_payout,
'bet_count' => $this->round->bet_count,
];
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* 文件功能:百家乐前台下注控制器
*
* 提供用户在聊天室内下注的 API 接口:
* - 查询当前局次信息
* - 提交下注(扣除金币 + 写入下注记录)
* - 查询本人在当前局的下注状态
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\UserCurrencyService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BaccaratController extends Controller
{
public function __construct(
private readonly UserCurrencyService $currency,
) {}
/**
* 获取当前进行中的局次信息(前端轮询或开局事件后调用)。
*/
public function currentRound(Request $request): JsonResponse
{
$round = BaccaratRound::currentRound();
if (! $round) {
return response()->json(['round' => null]);
}
$user = $request->user();
$myBet = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->first();
return response()->json([
'round' => [
'id' => $round->id,
'status' => $round->status,
'bet_closes_at' => $round->bet_closes_at->toIso8601String(),
'seconds_left' => max(0, (int) now()->diffInSeconds($round->bet_closes_at, false)),
'total_bet_big' => $round->total_bet_big,
'total_bet_small' => $round->total_bet_small,
'total_bet_triple' => $round->total_bet_triple,
'my_bet' => $myBet ? [
'bet_type' => $myBet->bet_type,
'amount' => $myBet->amount,
] : null,
],
]);
}
/**
* 用户提交下注。
*
* 同一局每人限下一注(后台强制幂等)。
* 下注成功后立即扣除金币,结算时中奖者才返还本金+赔付。
*/
public function bet(Request $request): JsonResponse
{
if (! GameConfig::isEnabled('baccarat')) {
return response()->json(['ok' => false, 'message' => '百家乐游戏当前未开启。']);
}
$data = $request->validate([
'round_id' => 'required|integer|exists:baccarat_rounds,id',
'bet_type' => 'required|in:big,small,triple',
'amount' => 'required|integer|min:1',
]);
$config = GameConfig::forGame('baccarat')?->params ?? [];
$minBet = (int) ($config['min_bet'] ?? 100);
$maxBet = (int) ($config['max_bet'] ?? 50000);
if ($data['amount'] < $minBet || $data['amount'] > $maxBet) {
return response()->json(['ok' => false, 'message' => "押注金额须在 {$minBet}~{$maxBet} 金币之间。"]);
}
$round = BaccaratRound::find($data['round_id']);
if (! $round || ! $round->isBettingOpen()) {
return response()->json(['ok' => false, 'message' => '当前不在下注时间内。']);
}
$user = $request->user();
// 检查用户金币余额
if ($user->gold < $data['amount']) {
return response()->json(['ok' => false, 'message' => '金币不足,无法下注。']);
}
$currency = $this->currency;
return DB::transaction(function () use ($user, $round, $data, $currency): JsonResponse {
// 幂等:同一局只能下一注
$existing = BaccaratBet::query()
->where('round_id', $round->id)
->where('user_id', $user->id)
->lockForUpdate()
->exists();
if ($existing) {
return response()->json(['ok' => false, 'message' => '本局您已下注,请等待开奖。']);
}
// 扣除金币
$currency->change(
$user,
'gold',
-$data['amount'],
CurrencySource::BACCARAT_BET,
"百家乐 #{$round->id}".match ($data['bet_type']) {
'big' => '大', 'small' => '小', default => '豹子'
},
);
// 写入下注记录
BaccaratBet::create([
'round_id' => $round->id,
'user_id' => $user->id,
'bet_type' => $data['bet_type'],
'amount' => $data['amount'],
'status' => 'pending',
]);
// 更新局次汇总统计
$field = 'total_bet_'.$data['bet_type'];
$round->increment($field, $data['amount']);
$round->increment('bet_count');
$betLabel = match ($data['bet_type']) {
'big' => '大', 'small' => '小', default => '豹子'
};
return response()->json([
'ok' => true,
'message' => "✅ 已押注「{$betLabel}{$data['amount']} 金币,等待开奖!",
'amount' => $data['amount'],
'bet_type' => $data['bet_type'],
]);
});
}
/**
* 查询最近5局的历史记录(前端展示趋势)。
*/
public function history(): JsonResponse
{
$rounds = BaccaratRound::query()
->where('status', 'settled')
->orderByDesc('id')
->limit(10)
->get(['id', 'dice1', 'dice2', 'dice3', 'total_points', 'result', 'settled_at']);
return response()->json(['history' => $rounds]);
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
/**
* 文件功能:百家乐结算队列任务
*
* 在押注截止时间到达后自动触发:
* 摇三颗骰子 判断大//豹子/庄家收割
* 遍历下注记录逐一发放赔付 广播结果 公屏公告
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Enums\CurrencySource;
use App\Events\BaccaratRoundSettled;
use App\Events\MessageSent;
use App\Models\BaccaratBet;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class CloseBaccaratRoundJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 3;
/**
* @param BaccaratRound $round 要结算的局次
*/
public function __construct(
public readonly BaccaratRound $round,
) {}
/**
* 执行结算逻辑。
*/
public function handle(
UserCurrencyService $currency,
ChatStateService $chatState,
): void {
$round = $this->round->fresh();
// 防止重复结算
if (! $round || $round->status !== 'betting') {
return;
}
$config = GameConfig::forGame('baccarat')?->params ?? [];
// 乐观锁:CAS 先把状态改为 settling
$updated = BaccaratRound::query()
->where('id', $round->id)
->where('status', 'betting')
->update(['status' => 'settling']);
if (! $updated) {
return; // 已被其他进程处理
}
// ── 摇骰子 ──────────────────────────────────────────────────
$dice = [random_int(1, 6), random_int(1, 6), random_int(1, 6)];
$total = array_sum($dice);
// ── 判断结果 ────────────────────────────────────────────────
$killPoints = $config['kill_points'] ?? [3, 18];
if (! is_array($killPoints)) {
$killPoints = explode(',', (string) $killPoints);
}
$killPoints = array_map('intval', $killPoints);
$result = match (true) {
$dice[0] === $dice[1] && $dice[1] === $dice[2] => 'triple', // 豹子(优先判断)
in_array($total, $killPoints, true) => 'kill', // 庄家收割
$total >= 11 && $total <= 17 => 'big', // 大
default => 'small', // 小
};
// ── 结算下注记录 ─────────────────────────────────────────────
$bets = BaccaratBet::query()->where('round_id', $round->id)->where('status', 'pending')->with('user')->get();
$totalPayout = 0;
DB::transaction(function () use ($bets, $result, $config, $currency, &$totalPayout) {
foreach ($bets as $bet) {
if ($result === 'kill') {
// 庄家收割:全灭无退款
$bet->update(['status' => 'lost', 'payout' => 0]);
continue;
}
if ($bet->bet_type === $result) {
// 中奖:计算赔付(含本金返还)
$payout = BaccaratRound::calcPayout($bet->bet_type, $bet->amount, $config);
$bet->update(['status' => 'won', 'payout' => $payout]);
// 金币入账
$currency->change(
$bet->user,
'gold',
$payout,
CurrencySource::BACCARAT_WIN,
"百家乐 #{$this->round->id}{$bet->betTypeLabel()} 中奖",
);
$totalPayout += $payout;
} else {
$bet->update(['status' => 'lost', 'payout' => 0]);
}
}
});
// ── 更新局次记录 ─────────────────────────────────────────────
$round->update([
'dice1' => $dice[0],
'dice2' => $dice[1],
'dice3' => $dice[2],
'total_points' => $total,
'result' => $result,
'status' => 'settled',
'settled_at' => now(),
'total_payout' => $totalPayout,
]);
$round->refresh();
// ── 广播结算事件 ─────────────────────────────────────────────
broadcast(new BaccaratRoundSettled($round));
// ── 公屏公告 ─────────────────────────────────────────────────
$this->pushResultMessage($round, $chatState);
}
/**
* 向公屏发送开奖结果系统消息。
*/
private function pushResultMessage(BaccaratRound $round, ChatStateService $chatState): void
{
$diceStr = "{$round->dice1}】【{$round->dice2}】【{$round->dice3}";
$resultText = match ($round->result) {
'big' => "🔵 大({$round->total_points} 点)",
'small' => "🟡 小({$round->total_points} 点)",
'triple' => "💥 豹子!({$round->dice1}{$round->dice1}{$round->dice1}",
'kill' => "☠️ 庄家收割!({$round->total_points} 点)全灭",
default => '',
};
$payoutText = $round->total_payout > 0
? '共派发 🪙'.number_format($round->total_payout).' 金币'
: '本局无人获奖';
$content = "🎲 【百家乐】第 #{$round->id} 局开奖!{$diceStr} 总点 {$round->total_points}{$resultText}{$payoutText}";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#8b5cf6',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* 文件功能:百家乐开局队列任务
*
* 由调度器每 N 分钟触发一次(N 来自 game_configs.params.interval_minutes),
* 游戏开启时创建新局次,广播开局事件,并在押注截止时间到期后自动调度结算任务。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Events\BaccaratRoundOpened;
use App\Events\MessageSent;
use App\Models\BaccaratRound;
use App\Models\GameConfig;
use App\Services\ChatStateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class OpenBaccaratRoundJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 1;
/**
* 执行开局逻辑。
*/
public function handle(ChatStateService $chatState): void
{
// 检查游戏是否开启
if (! GameConfig::isEnabled('baccarat')) {
return;
}
$config = GameConfig::forGame('baccarat')?->params ?? [];
$betSeconds = (int) ($config['bet_window_seconds'] ?? 60);
// 防止重复开局(如果上一局还在押注中则跳过)
if (BaccaratRound::currentRound()) {
return;
}
$now = now();
$closesAt = $now->copy()->addSeconds($betSeconds);
// 创建新局次
$round = BaccaratRound::create([
'status' => 'betting',
'bet_opens_at' => $now,
'bet_closes_at' => $closesAt,
]);
// 广播开局事件
broadcast(new BaccaratRoundOpened($round));
// 公屏系统公告
$minBet = number_format($config['min_bet'] ?? 100);
$maxBet = number_format($config['max_bet'] ?? 50000);
$content = "🎲 【百家乐】第 #{$round->id} 局开始!下注时间 {$betSeconds} 秒,可押「大/小/豹子」,押注范围 {$minBet}~{$maxBet} 金币。";
$msg = [
'id' => $chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#8b5cf6',
'action' => '大声宣告',
'sent_at' => $now->toDateTimeString(),
];
$chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
SaveMessageJob::dispatch($msg);
// 在下注截止时安排结算任务
CloseBaccaratRoundJob::dispatch($round)->delay($closesAt);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* 文件功能:百家乐下注记录模型
*
* 记录用户在某局中的押注信息和结算状态。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BaccaratBet extends Model
{
protected $fillable = [
'round_id',
'user_id',
'bet_type',
'amount',
'payout',
'status',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'amount' => 'integer',
'payout' => 'integer',
];
}
/**
* 关联局次。
*/
public function round(): BelongsTo
{
return $this->belongsTo(BaccaratRound::class, 'round_id');
}
/**
* 关联用户。
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 获取押注类型中文名。
*/
public function betTypeLabel(): string
{
return match ($this->bet_type) {
'big' => '大',
'small' => '小',
'triple' => '豹子',
default => '未知',
};
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* 文件功能:百家乐局次模型
*
* 代表一局百家乐游戏,包含骰子结果、局次状态、下注汇总等信息。
* 提供局次判断和赔率计算的辅助方法。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BaccaratRound extends Model
{
protected $fillable = [
'dice1', 'dice2', 'dice3',
'total_points', 'result', 'status',
'bet_opens_at', 'bet_closes_at', 'settled_at',
'total_bet_big', 'total_bet_small', 'total_bet_triple',
'total_payout', 'bet_count',
];
/**
* 属性类型转换。
*/
protected function casts(): array
{
return [
'bet_opens_at' => 'datetime',
'bet_closes_at' => 'datetime',
'settled_at' => 'datetime',
'dice1' => 'integer',
'dice2' => 'integer',
'dice3' => 'integer',
'total_points' => 'integer',
'total_bet_big' => 'integer',
'total_bet_small' => 'integer',
'total_bet_triple' => 'integer',
'total_payout' => 'integer',
'bet_count' => 'integer',
];
}
/**
* 该局的所有下注记录。
*/
public function bets(): HasMany
{
return $this->hasMany(BaccaratBet::class, 'round_id');
}
/**
* 判断当前是否在押注时间窗口内。
*/
public function isBettingOpen(): bool
{
return $this->status === 'betting'
&& now()->between($this->bet_opens_at, $this->bet_closes_at);
}
/**
* 计算指定押注类型和金额的预计回报(含本金)。
*
* @param string $betType 'big' | 'small' | 'triple'
* @param int $amount 押注金额
* @param array $config 游戏配置参数
*/
public static function calcPayout(string $betType, int $amount, array $config): int
{
$payout = match ($betType) {
'triple' => $amount * ($config['payout_triple'] + 1),
'big' => $amount * ($config['payout_big'] + 1),
'small' => $amount * ($config['payout_small'] + 1),
default => 0,
};
return (int) $payout;
}
/**
* 获取结果中文名称。
*/
public function resultLabel(): string
{
return match ($this->result) {
'big' => '大',
'small' => '小',
'triple' => "豹子({$this->dice1}{$this->dice1}{$this->dice1}",
'kill' => '庄家收割',
default => '未知',
};
}
/**
* 查询当前正在进行的局次(状态为 betting 且未截止)。
*/
public static function currentRound(): ?static
{
return static::query()
->where('status', 'betting')
->where('bet_closes_at', '>', now())
->latest()
->first();
}
}