Files
chatroom/app/Services/LotteryService.php
T
2026-04-30 15:41:50 +08:00

498 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:双色球彩票核心服务
*
* 负责购票、开奖、奖级判定、奖池管理、派奖及公屏广播全流程。
* 所有金币变动通过 UserCurrencyService::change() 执行并记录流水。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryPoolLog;
use App\Models\LotteryTicket;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* 类功能:负责双色球购票、开奖、滚存与房间广播。
*/
class LotteryService
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly ChatStateService $chatState,
private readonly GameRoomScopeService $roomScopeService,
private readonly GameBetBroadcastService $betBroadcastService,
) {}
// ─── 购票 ─────────────────────────────────────────────────────────
/**
* 用户购买一注或多注彩票。
*
* @param User $user 购票用户
* @param array<array> $numbers 每注号码:[['reds'=>[3,8,12],'blue'=>4], ...]
* @param bool $quickPick 是否机选
* @return array<LotteryTicket> 新建的购票记录列表
*
* @throws \RuntimeException
*/
public function buyTickets(User $user, array $numbers, bool $quickPick = false): array
{
$config = GameConfig::forGame('lottery')?->params ?? [];
if (! GameConfig::isEnabled('lottery')) {
throw new \RuntimeException('双色球彩票游戏未开启');
}
$roomId = $this->roomScopeService->resolveUserRoomId($user);
$issue = LotteryIssue::currentIssue($roomId);
if (! $issue || ! $issue->isOpen()) {
throw new \RuntimeException('当前无正在进行的期次,或已停售');
}
$ticketPrice = (int) ($config['ticket_price'] ?? 100);
$maxPerUser = (int) ($config['max_tickets_per_user'] ?? 50);
$maxPerBuy = (int) ($config['max_tickets_per_buy'] ?? 10);
$poolRatio = (int) ($config['pool_ratio'] ?? 70);
// 本期已购注数
$alreadyBought = LotteryTicket::query()
->where('issue_id', $issue->id)
->where('user_id', $user->id)
->count();
$buyCount = count($numbers);
if ($buyCount > $maxPerBuy) {
throw new \RuntimeException("单次最多购买 {$maxPerBuy}");
}
if ($alreadyBought + $buyCount > $maxPerUser) {
$remain = $maxPerUser - $alreadyBought;
throw new \RuntimeException("本期单人最多 {$maxPerUser} 注,您还可购 {$remain}");
}
$totalCost = $ticketPrice * $buyCount;
if ($user->jjb < $totalCost) {
throw new \RuntimeException("金币不足,需要 {$totalCost} 金币");
}
$tickets = [];
DB::transaction(function () use (
$user, $numbers, $issue, $ticketPrice, $poolRatio, $quickPick, &$tickets
) {
foreach ($numbers as $idx => $num) {
// ...existing validation/DB storing logic...
$reds = $num['reds'];
sort($reds); // 红球排序存储,方便比对
/** @var LotteryTicket $ticket */
$ticket = LotteryTicket::create([
'issue_id' => $issue->id,
'user_id' => $user->id,
'red1' => $reds[0],
'red2' => $reds[1],
'red3' => $reds[2],
'blue' => $num['blue'],
'amount' => $ticketPrice,
'is_quick_pick' => $quickPick,
'prize_level' => 0,
'payout' => 0,
]);
$tickets[] = $ticket;
// 扣除购票金币并记录流水
$this->currency->change(
user: $user,
currency: 'gold',
amount: -$ticketPrice,
source: CurrencySource::LOTTERY_BUY,
remark: "双色球 #{$issue->issue_no}".($idx + 1).'注 '.$ticket->numbersLabel(),
);
// 记录奖池流水(购票入池部分)
$poolIn = (int) round($ticketPrice * $poolRatio / 100);
$issue->increment('pool_amount', $poolIn);
$issue->increment('total_tickets');
LotteryPoolLog::create([
'issue_id' => $issue->id,
'change_amount' => $poolIn,
'reason' => 'ticket_sale',
'pool_after' => $issue->fresh()->pool_amount,
'remark' => "用户 {$user->username} 购第".($idx + 1).'注',
'created_at' => now(),
]);
}
});
// 用户成功购买后,发送系统传音广播(大家都能看到他买了彩票)
$firstTicket = $tickets[0];
$numsStr = $firstTicket->numbersLabel();
$this->betBroadcastService->lottery((int) $issue->room_id, $user->username, $issue->issue_no, $numsStr, $buyCount);
return $tickets;
}
/**
* 机选号码(随机生成一注或多注)。
*
* @param int $count 生成注数
* @return array<array> 格式:[['reds'=>[...], 'blue'=>n], ...]
*/
public function quickPick(int $count = 1): array
{
$result = [];
for ($i = 0; $i < $count; $i++) {
$reds = [];
while (count($reds) < 3) {
$n = random_int(1, 12);
if (! in_array($n, $reds, true)) {
$reds[] = $n;
}
}
sort($reds);
$result[] = [
'reds' => $reds,
'blue' => random_int(1, 6),
];
}
return $result;
}
// ─── 开奖 ─────────────────────────────────────────────────────────
/**
* 执行开奖:随机号码 → 逐票结算 → 奖池派发 → 公屏广播。
*
* 由 DrawLotteryJob 在每日指定时间调用。
*/
public function draw(LotteryIssue $issue): void
{
// 防重复开奖
if ($issue->status !== 'closed') {
return;
}
$config = GameConfig::forGame('lottery')?->params ?? [];
// ── 生成开奖号码 ──
$reds = [];
while (count($reds) < 3) {
$n = random_int(1, 12);
if (! in_array($n, $reds, true)) {
$reds[] = $n;
}
}
sort($reds);
$blue = random_int(1, 6);
// 写入开奖号码
$issue->update([
'red1' => $reds[0],
'red2' => $reds[1],
'red3' => $reds[2],
'blue' => $blue,
'draw_at' => now(),
]);
$issue->refresh();
// ── 加载所有购票记录 ──
$tickets = LotteryTicket::query()
->where('issue_id', $issue->id)
->with('user')
->get();
$poolAmount = $issue->pool_amount;
// ── 先扣除固定小奖(四/五等,从奖池中扣)──
$prize4Fixed = (int) ($config['prize_4th_fixed'] ?? 150);
$prize5Fixed = (int) ($config['prize_5th_fixed'] ?? 50);
$prize4Tickets = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 4);
$prize5Tickets = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 5);
$totalFixed = ($prize4Tickets->count() * $prize4Fixed) + ($prize5Tickets->count() * $prize5Fixed);
// 若奖池不足则按比例缩减固定奖
$fixedScale = $totalFixed > 0 && $totalFixed > $poolAmount
? $poolAmount / $totalFixed
: 1.0;
$poolAfterFixed = max(0, $poolAmount - (int) round($totalFixed * $fixedScale));
// ── 计算大奖(一/二/三等)可用奖池 ──
$prize1Ratio = (int) ($config['prize_1st_ratio'] ?? 60);
$prize2Ratio = (int) ($config['prize_2nd_ratio'] ?? 20);
$prize3Ratio = (int) ($config['prize_3rd_ratio'] ?? 10);
$carryRatio = (int) ($config['carry_ratio'] ?? 10);
$pool1 = (int) round($poolAfterFixed * $prize1Ratio / 100);
$pool2 = (int) round($poolAfterFixed * $prize2Ratio / 100);
$pool3 = (int) round($poolAfterFixed * $prize3Ratio / 100);
// ── 逐票结算 ──
$totalPayout = 0;
$winner1Names = [];
$winner2Names = [];
$winner3Names = [];
$prize4Count = 0;
$prize5Count = 0;
// 先统计各奖级人数(用于均分)
$count1 = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 1)->count();
$count2 = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 2)->count();
$count3 = $tickets->filter(fn ($t) => $issue->calcPrizeLevel($t->red1, $t->red2, $t->red3, $t->blue) === 3)->count();
$payout1Each = $count1 > 0 ? (int) floor($pool1 / $count1) : 0;
$payout2Each = $count2 > 0 ? (int) floor($pool2 / $count2) : 0;
$payout3Each = $count3 > 0 ? (int) floor($pool3 / $count3) : 0;
DB::transaction(function () use (
$tickets, $issue, $prize4Fixed, $prize5Fixed, $fixedScale,
$payout1Each, $payout2Each, $payout3Each,
&$totalPayout, &$winner1Names, &$winner2Names, &$winner3Names,
&$prize4Count, &$prize5Count
) {
foreach ($tickets as $ticket) {
$level = $issue->calcPrizeLevel($ticket->red1, $ticket->red2, $ticket->red3, $ticket->blue);
$payout = 0;
switch ($level) {
case 1:
$payout = $payout1Each;
$winner1Names[] = $ticket->user->username.'+'.\number_format($payout);
break;
case 2:
$payout = $payout2Each;
$winner2Names[] = $ticket->user->username.'+'.\number_format($payout);
break;
case 3:
$payout = $payout3Each;
$winner3Names[] = $ticket->user->username.'+'.\number_format($payout);
break;
case 4:
$payout = (int) round($prize4Fixed * $fixedScale);
$prize4Count++;
break;
case 5:
$payout = (int) round($prize5Fixed * $fixedScale);
$prize5Count++;
break;
}
$ticket->update(['prize_level' => $level, 'payout' => $payout]);
if ($payout > 0) {
$totalPayout += $payout;
$this->currency->change(
user: $ticket->user,
currency: 'gold',
amount: $payout,
source: CurrencySource::LOTTERY_WIN,
remark: "双色球 #{$issue->issue_no} {$issue::prizeLevelLabel($level)}中奖,得 {$payout} 金币",
);
}
}
});
// ── 计算滚存金额 ──
$usedPool = $totalPayout;
$carryAmount = max(0, $poolAfterFixed - $usedPool + (int) round($poolAfterFixed * $carryRatio / 100));
// 无人中一/二/三等时该部分奖池全部滚存
$actualCarry = $poolAfterFixed - min($usedPool, $poolAfterFixed);
// ── 更新期次记录 ──
$noWinnerStreak = $count1 === 0 ? $issue->no_winner_streak + 1 : 0;
$issue->update([
'status' => 'settled',
'payout_amount' => $totalPayout,
'no_winner_streak' => $noWinnerStreak,
]);
// ── 记录奖池派奖流水 ──
LotteryPoolLog::create([
'issue_id' => $issue->id,
'change_amount' => -$totalPayout,
'reason' => 'payout',
'pool_after' => max(0, $poolAfterFixed - $totalPayout),
'remark' => "开奖派奖,共 {$totalPayout} 金币",
'created_at' => now(),
]);
// ── 开启下一期(携带滚存)──
$this->openNextIssue($issue, $actualCarry, $noWinnerStreak);
// ── 公屏广播 ──
$this->broadcastResult($issue, $count1, $count2, $count3, $prize4Count, $prize5Count, $winner1Names);
}
/**
* 开启新一期,携带上期滚存金额。
*
* @param LotteryIssue $prevIssue 上一期
* @param int $carryAmount 滚存金额
* @param int $noWinnerStreak 连续无一等奖期数
*/
private function openNextIssue(LotteryIssue $prevIssue, int $carryAmount, int $noWinnerStreak): void
{
$config = GameConfig::forGame('lottery')?->params ?? [];
$drawHour = (int) ($config['draw_hour'] ?? 20);
$drawMinute = (int) ($config['draw_minute'] ?? 0);
$stopMinutes = (int) ($config['stop_sell_minutes'] ?? 2);
// 下一期开奖时间(明天同一时间)
$drawAt = now()->addDay()->setTime($drawHour, $drawMinute, 0);
$closeAt = $drawAt->copy()->subMinutes($stopMinutes);
// 判断是否超级期
$superThreshold = (int) ($config['super_issue_threshold'] ?? 3);
$isSuper = $noWinnerStreak >= $superThreshold;
$injectAmount = 0;
if ($isSuper) {
$injectAmount = (int) ($config['super_issue_inject'] ?? 20000);
}
$newIssue = LotteryIssue::create([
'room_id' => (int) $prevIssue->room_id,
'issue_no' => LotteryIssue::nextIssueNo((int) $prevIssue->room_id),
'status' => 'open',
'pool_amount' => $carryAmount + $injectAmount,
'carry_amount' => $carryAmount,
'is_super_issue' => $isSuper,
'no_winner_streak' => $noWinnerStreak,
'sell_closes_at' => $closeAt,
'draw_at' => $drawAt,
]);
// 记录滚存流水
if ($carryAmount > 0) {
LotteryPoolLog::create([
'issue_id' => $newIssue->id,
'change_amount' => $carryAmount,
'reason' => 'carry_over',
'pool_after' => $newIssue->pool_amount,
'remark' => "从第 #{$prevIssue->issue_no} 期滚存",
'created_at' => now(),
]);
}
// 记录系统注入流水
if ($injectAmount > 0) {
LotteryPoolLog::create([
'issue_id' => $newIssue->id,
'change_amount' => $injectAmount,
'reason' => 'system_inject',
'pool_after' => $newIssue->pool_amount,
'remark' => "超级期系统注入(连续 {$noWinnerStreak} 期无一等奖)",
'created_at' => now(),
]);
}
// 超级期全服预热广播
if ($isSuper) {
$this->broadcastSuperIssue($newIssue);
}
}
/**
* 向公屏广播开奖结果。
*
* @param array<string> $winner1Names 一等奖得主列表
*/
private function broadcastResult(
LotteryIssue $issue,
int $count1, int $count2, int $count3,
int $prize4Count, int $prize5Count,
array $winner1Names
): void {
$drawNums = sprintf(
'🔴%02d 🔴%02d 🔴%02d + 🎲%d',
$issue->red1, $issue->red2, $issue->red3, $issue->blue
);
// 一等奖
if ($count1 > 0) {
$w1Str = implode('、', array_slice($winner1Names, 0, 5));
$line1 = "🏆 一等奖:{$w1Str} 💰";
} else {
$line1 = '无一等奖!奖池滚存 → 下期累计 💰 '.number_format($issue->pool_amount).' 金币';
}
$details = [];
if ($count2 > 0) {
$details[] = "🥇 二等奖:{$count2}";
}
if ($count3 > 0) {
$details[] = "🥈 三等奖:{$count3}";
}
if ($prize4Count > 0) {
$details[] = "🥉 四等奖:{$prize4Count}";
}
if ($prize5Count > 0) {
$details[] = "🎫 五等奖:{$prize5Count}";
}
$detailStr = $details ? ' '.implode(' | ', $details) : '';
$content = "🎟️ 第 #{$issue->issue_no} 期开奖:{$drawNums} {$line1}{$detailStr}";
$this->pushSystemMessage($content, (int) $issue->room_id);
// 触发微信机器人消息推送 (彩票开奖)
try {
$wechatService = app(\App\Services\WechatBot\WechatNotificationService::class);
$wechatService->notifyLotteryResult($content);
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::error('WechatBot lottery notification failed', ['error' => $e->getMessage()]);
}
}
/**
* 超级期预热广播。
*/
private function broadcastSuperIssue(LotteryIssue $issue): void
{
$pool = number_format($issue->pool_amount);
$content = "🎊 第 #{$issue->issue_no} 期超级期:已连续 {$issue->no_winner_streak} 期无一等奖,奖池 💰 {$pool}{$issue->draw_at?->format('H:i')} 开奖。";
$this->pushSystemMessage($content, (int) $issue->room_id);
}
/**
* 向公屏发送系统消息。
*/
private function pushSystemMessage(string $content, int $roomId): void
{
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
}
}