- 新增:管理员后台的微信机器人双向收发参数设置页面及扫码绑定能力。 - 新增:WechatBotApiService 与 KafkaConsumerService 模块打通过往僵尸进程导致的拒绝连接问题。 - 新增:下发所有群发/私聊通知时统一带上「[和平聊吧]」标注前缀。 - 优化:前端个人中心绑定逻辑支持一键生成及复制动态口令。 - 修复:闭环联调修补各个模型中产生的变量警告如 stdClass 对象获取等异常预警。
493 lines
18 KiB
PHP
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);
|
|
}
|
|
}
|