Files
chatroom/app/Services/LotteryService.php
lkddi fc57f97c9e feat(wechat): 微信机器人全链路集成与稳定性修复
- 新增:管理员后台的微信机器人双向收发参数设置页面及扫码绑定能力。
- 新增:WechatBotApiService 与 KafkaConsumerService 模块打通过往僵尸进程导致的拒绝连接问题。
- 新增:下发所有群发/私聊通知时统一带上「[和平聊吧]」标注前缀。
- 优化:前端个人中心绑定逻辑支持一键生成及复制动态口令。
- 修复:闭环联调修补各个模型中产生的变量警告如 stdClass 对象获取等异常预警。
2026-04-02 14:56:51 +08:00

493 lines
18 KiB
PHP

<?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,
) {}
// ─── 购票 ─────────────────────────────────────────────────────────
/**
* 用户购买一注或多注彩票。
*
* @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('双色球彩票游戏未开启');
}
$issue = LotteryIssue::currentIssue();
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();
$moreStr = $buyCount > 1 ? "{$buyCount} 注号码" : '';
$this->pushSystemMessage("🎟️ 【双色球彩票】财神爷保佑!玩家【{$user->username}】豪掷千金,购买了当前 #{$issue->issue_no} 期双色球 {$numsStr} {$moreStr},祝 Ta 中大奖!");
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([
'issue_no' => LotteryIssue::nextIssueNo(),
'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);
// 触发微信机器人消息推送 (彩票开奖)
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);
}
/**
* 向公屏发送系统消息。
*/
private function pushSystemMessage(string $content): void
{
$msg = [
'id' => $this->chatState->nextMessageId(1),
'room_id' => 1,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#dc2626',
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage(1, $msg);
broadcast(new MessageSent(1, $msg));
\App\Jobs\SaveMessageJob::dispatch($msg);
}
}