diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 1296871..eb6c629 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -111,6 +111,12 @@ enum CurrencySource: string /** 神秘占卜——额外次数消耗(扣除金币) */ case FORTUNE_COST = 'fortune_cost'; + /** 双色球购票消耗(每注扣除 ticket_price 金币) */ + case LOTTERY_BUY = 'lottery_buy'; + + /** 双色球中奖派奖(所有奖级统一用此 source,备注写奖级详情) */ + case LOTTERY_WIN = 'lottery_win'; + /** * 返回该来源的中文名称,用于后台统计展示。 */ @@ -147,6 +153,8 @@ enum CurrencySource: string self::HORSE_BET => '赛马下注', self::HORSE_WIN => '赛马赢钱', self::FORTUNE_COST => '神秘占卜消耗', + self::LOTTERY_BUY => '双色球购票', + self::LOTTERY_WIN => '双色球中奖', }; } } diff --git a/app/Http/Controllers/LotteryController.php b/app/Http/Controllers/LotteryController.php new file mode 100644 index 0000000..f5e58e1 --- /dev/null +++ b/app/Http/Controllers/LotteryController.php @@ -0,0 +1,180 @@ +json(['enabled' => false]); + } + + $issue = LotteryIssue::currentIssue() ?? LotteryIssue::latestIssue(); + + if (! $issue) { + return response()->json(['enabled' => true, 'issue' => null]); + } + + $myTickets = LotteryTicket::query() + ->where('issue_id', $issue->id) + ->where('user_id', Auth::id()) + ->orderBy('id') + ->get() + ->map(fn ($t) => [ + 'id' => $t->id, + 'numbers' => $t->numbersLabel(), + 'red1' => $t->red1, + 'red2' => $t->red2, + 'red3' => $t->red3, + 'blue' => $t->blue, + 'is_quick' => $t->is_quick_pick, + 'prize_level' => $t->prize_level, + 'payout' => $t->payout, + ]); + + return response()->json([ + 'enabled' => true, + 'is_open' => $issue->isOpen(), + 'issue' => [ + 'id' => $issue->id, + 'issue_no' => $issue->issue_no, + 'status' => $issue->status, + 'pool_amount' => $issue->pool_amount, + 'is_super_issue' => $issue->is_super_issue, + 'no_winner_streak' => $issue->no_winner_streak, + 'seconds_left' => $issue->secondsUntilDraw(), + 'draw_at' => $issue->draw_at?->toDateTimeString(), + 'sell_closes_at' => $issue->sell_closes_at?->toDateTimeString(), + 'red1' => $issue->red1, + 'red2' => $issue->red2, + 'red3' => $issue->red3, + 'blue' => $issue->blue, + ], + 'my_tickets' => $myTickets, + 'my_ticket_count' => $myTickets->count(), + ]); + } + + /** + * 购票接口:支持自选和机选,支持一次购买多注。 + */ + public function buy(Request $request): JsonResponse + { + $request->validate([ + 'numbers' => 'required|array|min:1', + 'numbers.*.reds' => 'required|array|size:3', + 'numbers.*.reds.*' => 'required|integer|min:1|max:12', + 'numbers.*.blue' => 'required|integer|min:1|max:6', + 'quick_pick' => 'boolean', + ]); + + try { + $tickets = $this->lottery->buyTickets( + user: Auth::user(), + numbers: $request->input('numbers'), + quickPick: (bool) $request->input('quick_pick', false), + ); + + return response()->json([ + 'status' => 'success', + 'message' => '购票成功!共 '.count($tickets).' 注', + 'count' => count($tickets), + ]); + } catch (\RuntimeException $e) { + return response()->json(['status' => 'error', 'message' => $e->getMessage()], 422); + } + } + + /** + * 机选号码接口(仅生成号码,不扣费,供前端展示后确认购买)。 + */ + public function quickPick(Request $request): JsonResponse + { + $count = min((int) $request->input('count', 1), 10); + + return response()->json([ + 'numbers' => $this->lottery->quickPick($count), + ]); + } + + /** + * 历史期次列表。 + */ + public function history(): JsonResponse + { + $issues = LotteryIssue::query() + ->where('status', 'settled') + ->latest() + ->limit(20) + ->get() + ->map(fn ($i) => [ + 'issue_no' => $i->issue_no, + 'red1' => $i->red1, + 'red2' => $i->red2, + 'red3' => $i->red3, + 'blue' => $i->blue, + 'pool_amount' => $i->pool_amount, + 'payout_amount' => $i->payout_amount, + 'total_tickets' => $i->total_tickets, + 'is_super_issue' => $i->is_super_issue, + 'no_winner_streak' => $i->no_winner_streak, + 'draw_at' => $i->draw_at?->toDateTimeString(), + ]); + + return response()->json(['issues' => $issues]); + } + + /** + * 我的购票记录(跨期次)。 + */ + public function my(): JsonResponse + { + $tickets = LotteryTicket::query() + ->where('user_id', Auth::id()) + ->with('issue:id,issue_no,status,red1,red2,red3,blue,draw_at') + ->latest() + ->limit(50) + ->get() + ->map(fn ($t) => [ + 'issue_no' => $t->issue?->issue_no, + 'status' => $t->issue?->status, + 'numbers' => $t->numbersLabel(), + 'prize_level' => $t->prize_level, + 'payout' => $t->payout, + 'is_quick' => $t->is_quick_pick, + 'created_at' => $t->created_at->toDateTimeString(), + ]); + + return response()->json(['tickets' => $tickets]); + } +} diff --git a/app/Jobs/DrawLotteryJob.php b/app/Jobs/DrawLotteryJob.php new file mode 100644 index 0000000..7aa6ea2 --- /dev/null +++ b/app/Jobs/DrawLotteryJob.php @@ -0,0 +1,58 @@ +issue->fresh(); + + // 防止重复开奖 + if (! $issue || $issue->status === 'settled') { + return; + } + + // 标记为已停售(closed),阻止新购票 + if ($issue->status === 'open') { + $issue->update(['status' => 'closed']); + $issue->refresh(); + } + + // 执行开奖 + $lottery->draw($issue); + } +} diff --git a/app/Jobs/OpenLotteryIssueJob.php b/app/Jobs/OpenLotteryIssueJob.php new file mode 100644 index 0000000..3b970f8 --- /dev/null +++ b/app/Jobs/OpenLotteryIssueJob.php @@ -0,0 +1,69 @@ +params ?? []; + $drawHour = (int) ($config['draw_hour'] ?? 20); + $drawMinute = (int) ($config['draw_minute'] ?? 0); + $stopMinutes = (int) ($config['stop_sell_minutes'] ?? 2); + + // 今天的开奖时间;若当前时间已过今日开奖时间,则用明天 + $drawAt = now()->setTime($drawHour, $drawMinute, 0); + if ($drawAt->isPast()) { + $drawAt->addDay(); + } + + $closeAt = $drawAt->copy()->subMinutes($stopMinutes); + + LotteryIssue::create([ + 'issue_no' => LotteryIssue::nextIssueNo(), + 'status' => 'open', + 'pool_amount' => 0, + 'carry_amount' => 0, + 'is_super_issue' => false, + 'no_winner_streak' => 0, + 'sell_closes_at' => $closeAt, + 'draw_at' => $drawAt, + ]); + } +} diff --git a/app/Models/LotteryIssue.php b/app/Models/LotteryIssue.php new file mode 100644 index 0000000..6555d0f --- /dev/null +++ b/app/Models/LotteryIssue.php @@ -0,0 +1,164 @@ + 'boolean', + 'pool_amount' => 'integer', + 'carry_amount' => 'integer', + 'payout_amount' => 'integer', + 'total_tickets' => 'integer', + 'sell_closes_at' => 'datetime', + 'draw_at' => 'datetime', + ]; + } + + // ─── 关联 ────────────────────────────────────────────────────────── + + /** + * 本期所有购票记录。 + */ + public function tickets(): HasMany + { + return $this->hasMany(LotteryTicket::class, 'issue_id'); + } + + /** + * 本期奖池流水。 + */ + public function poolLogs(): HasMany + { + return $this->hasMany(LotteryPoolLog::class, 'issue_id'); + } + + // ─── 静态查询 ────────────────────────────────────────────────────── + + /** + * 获取当前正在购票的期次(status=open)。 + */ + public static function currentIssue(): ?static + { + return static::query()->where('status', 'open')->latest()->first(); + } + + /** + * 获取最新一期(不论状态)。 + */ + public static function latestIssue(): ?static + { + return static::query()->latest()->first(); + } + + /** + * 生成下一期的期号(格式:年份 + 三位序号,如 2026001)。 + */ + public static function nextIssueNo(): string + { + $year = now()->year; + $last = static::query() + ->whereYear('created_at', $year) + ->latest() + ->first(); + + $seq = $last ? ((int) substr($last->issue_no, -3)) + 1 : 1; + + return $year.str_pad($seq, 3, '0', STR_PAD_LEFT); + } + + // ─── 业务方法 ────────────────────────────────────────────────────── + + /** + * 判断当前期次是否仍在售票中。 + */ + public function isOpen(): bool + { + return $this->status === 'open' && now()->lt($this->sell_closes_at); + } + + /** + * 距离开奖剩余秒数。 + */ + public function secondsUntilDraw(): int + { + if (! $this->draw_at) { + return 0; + } + + return max(0, (int) now()->diffInSeconds($this->draw_at, false)); + } + + /** + * 判断给定号码是否与开奖号码匹配并返回奖级(0=未中)。 + * + * @param int $r1 用户红球1(已排序) + * @param int $r2 用户红球2 + * @param int $r3 用户红球3 + * @param int $b 用户蓝球 + */ + public function calcPrizeLevel(int $r1, int $r2, int $r3, int $b): int + { + $userReds = [$r1, $r2, $r3]; + $drawReds = [$this->red1, $this->red2, $this->red3]; + $redMatches = count(array_intersect($userReds, $drawReds)); + $blueMatch = ($b === $this->blue); + + return match (true) { + $redMatches === 3 && $blueMatch => 1, // 一等奖 + $redMatches === 3 && ! $blueMatch => 2, // 二等奖 + $redMatches === 2 && $blueMatch => 3, // 三等奖 + $redMatches === 2 && ! $blueMatch => 4, // 四等奖 + $redMatches === 1 && $blueMatch => 5, // 五等奖 + default => 0, // 未中奖 + }; + } + + /** + * 返回奖级的中文标签。 + */ + public static function prizeLevelLabel(int $level): string + { + return match ($level) { + 1 => '一等奖', + 2 => '二等奖', + 3 => '三等奖', + 4 => '四等奖', + 5 => '五等奖', + default => '未中奖', + }; + } +} diff --git a/app/Models/LotteryPoolLog.php b/app/Models/LotteryPoolLog.php new file mode 100644 index 0000000..82a5226 --- /dev/null +++ b/app/Models/LotteryPoolLog.php @@ -0,0 +1,72 @@ + 'integer', + 'pool_after' => 'integer', + 'created_at' => 'datetime', + ]; + } + + // ─── 关联 ────────────────────────────────────────────────────────── + + /** + * 关联的期次。 + */ + public function issue(): BelongsTo + { + return $this->belongsTo(LotteryIssue::class, 'issue_id'); + } + + // ─── 业务方法 ────────────────────────────────────────────────────── + + /** + * 返回 reason 的中文标签。 + */ + public function reasonLabel(): string + { + return match ($this->reason) { + 'ticket_sale' => '购票入池', + 'carry_over' => '上期滚存', + 'admin_inject' => '管理员注入', + 'system_inject' => '超级期系统注入', + 'payout' => '派奖扣除', + 'prize_4th' => '四等奖固定扣除', + 'prize_5th' => '五等奖固定扣除', + default => $this->reason, + }; + } +} diff --git a/app/Models/LotteryTicket.php b/app/Models/LotteryTicket.php new file mode 100644 index 0000000..14e7382 --- /dev/null +++ b/app/Models/LotteryTicket.php @@ -0,0 +1,83 @@ + 'boolean', + 'prize_level' => 'integer', + 'payout' => 'integer', + 'amount' => 'integer', + ]; + } + + // ─── 关联 ────────────────────────────────────────────────────────── + + /** + * 关联的期次。 + */ + public function issue(): BelongsTo + { + return $this->belongsTo(LotteryIssue::class, 'issue_id'); + } + + /** + * 关联的购票用户。 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // ─── 业务方法 ────────────────────────────────────────────────────── + + /** + * 返回本注选号的格式化字符串(用于备注和展示)。 + * + * @return string 如:红03 08 12 蓝4 + */ + public function numbersLabel(): string + { + return sprintf( + '红%02d %02d %02d 蓝%d', + $this->red1, $this->red2, $this->red3, $this->blue + ); + } + + /** + * 是否已中奖。 + */ + public function isWon(): bool + { + return $this->prize_level > 0; + } +} diff --git a/app/Services/LotteryService.php b/app/Services/LotteryService.php new file mode 100644 index 0000000..9f8e70d --- /dev/null +++ b/app/Services/LotteryService.php @@ -0,0 +1,477 @@ + $numbers 每注号码:[['reds'=>[3,8,12],'blue'=>4], ...] + * @param bool $quickPick 是否机选 + * @return array 新建的购票记录列表 + * + * @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) { + $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(), + ]); + } + }); + + return $tickets; + } + + /** + * 机选号码(随机生成一注或多注)。 + * + * @param int $count 生成注数 + * @return 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 $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); + } + + /** + * 超级期预热广播。 + */ + 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); + } +} diff --git a/app/app/Services/LotteryService.php b/app/app/Services/LotteryService.php new file mode 100644 index 0000000..f0975f8 --- /dev/null +++ b/app/app/Services/LotteryService.php @@ -0,0 +1,14 @@ +id(); + + // 期号,如 2026063(年份+当年第几期) + $table->string('issue_no', 20)->unique()->comment('期号'); + + // 状态:open=购票中 / closed=已停售 / settled=已开奖 + $table->string('status', 20)->default('open')->comment('状态'); + + // 开奖号码(开奖后写入) + $table->unsignedTinyInteger('red1')->nullable()->comment('开奖红球1'); + $table->unsignedTinyInteger('red2')->nullable()->comment('开奖红球2'); + $table->unsignedTinyInteger('red3')->nullable()->comment('开奖红球3'); + $table->unsignedTinyInteger('blue')->nullable()->comment('开奖蓝球'); + + // 奖池金额 + $table->unsignedBigInteger('pool_amount')->default(0)->comment('本期奖池金额(含滚存)'); + $table->unsignedBigInteger('carry_amount')->default(0)->comment('从上期滚存来的金额'); + + // 超级期标记 + $table->boolean('is_super_issue')->default(false)->comment('是否超级期'); + $table->unsignedTinyInteger('no_winner_streak')->default(0)->comment('截止本期连续无一等奖期数'); + + // 统计 + $table->unsignedInteger('total_tickets')->default(0)->comment('本期购票总注数'); + $table->unsignedBigInteger('payout_amount')->default(0)->comment('实际派奖总额'); + + // 时间 + $table->timestamp('sell_closes_at')->nullable()->comment('停售时间'); + $table->timestamp('draw_at')->nullable()->comment('开奖时间'); + $table->timestamps(); + + $table->index('status'); + $table->index('draw_at'); + }); + } + + /** + * 回滚迁移。 + */ + public function down(): void + { + Schema::dropIfExists('lottery_issues'); + } +}; diff --git a/database/migrations/2026_03_04_152933_create_lottery_pool_logs_table.php b/database/migrations/2026_03_04_152933_create_lottery_pool_logs_table.php new file mode 100644 index 0000000..87821de --- /dev/null +++ b/database/migrations/2026_03_04_152933_create_lottery_pool_logs_table.php @@ -0,0 +1,53 @@ +id(); + + $table->foreignId('issue_id')->constrained('lottery_issues')->cascadeOnDelete()->comment('关联期次 ID'); + + // 变动金额(正数=增加,负数=减少) + $table->bigInteger('change_amount')->comment('变动金额'); + + // 变动原因:ticket_sale/carry_over/admin_inject/system_inject/payout/prize_4th/prize_5th + $table->string('reason', 30)->comment('变动原因'); + + // 变动后奖池余额快照 + $table->unsignedBigInteger('pool_after')->comment('变动后奖池金额'); + + // 备注(可选,如:第X注购票、N等奖派奖等) + $table->string('remark', 200)->nullable()->comment('备注'); + + $table->timestamp('created_at')->useCurrent(); + + $table->index(['issue_id', 'reason']); + }); + } + + /** + * 回滚迁移。 + */ + public function down(): void + { + Schema::dropIfExists('lottery_pool_logs'); + } +}; diff --git a/database/migrations/2026_03_04_152933_create_lottery_tickets_table.php b/database/migrations/2026_03_04_152933_create_lottery_tickets_table.php new file mode 100644 index 0000000..f969e8d --- /dev/null +++ b/database/migrations/2026_03_04_152933_create_lottery_tickets_table.php @@ -0,0 +1,58 @@ +id(); + + $table->foreignId('issue_id')->constrained('lottery_issues')->cascadeOnDelete()->comment('期次 ID'); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('购票用户 ID'); + + // 用户选号(红球已排序存储,方便比对) + $table->unsignedTinyInteger('red1')->comment('用户选红球1(较小)'); + $table->unsignedTinyInteger('red2')->comment('用户选红球2'); + $table->unsignedTinyInteger('red3')->comment('用户选红球3(较大)'); + $table->unsignedTinyInteger('blue')->comment('用户选蓝球'); + + // 购票信息 + $table->unsignedInteger('amount')->comment('购票金额(每注固定 ticket_price)'); + $table->boolean('is_quick_pick')->default(false)->comment('是否机选'); + + // 开奖结果(开奖后写入) + $table->unsignedTinyInteger('prize_level')->default(0)->comment('中奖等级:0=未中 1-5=各等级'); + $table->unsignedBigInteger('payout')->default(0)->comment('实际派奖金额'); + + $table->timestamps(); + + $table->index(['issue_id', 'user_id']); + $table->index(['user_id', 'created_at']); + $table->index('prize_level'); + }); + } + + /** + * 回滚迁移。 + */ + public function down(): void + { + Schema::dropIfExists('lottery_tickets'); + } +}; diff --git a/database/seeders/GameConfigSeeder.php b/database/seeders/GameConfigSeeder.php index c278abc..70505ff 100644 --- a/database/seeders/GameConfigSeeder.php +++ b/database/seeders/GameConfigSeeder.php @@ -133,6 +133,41 @@ class GameConfigSeeder extends Seeder 'fishing_cooldown' => 300, // 收竿后冷却秒数 ], ], + + // ─── 双色球彩票 ────────────────────────────────────────────── + [ + 'game_key' => 'lottery', + 'name' => '双色球彩票', + 'icon' => '🎟️', + 'description' => '每日一期,选3红球(1-12)+1蓝球(1-6),按奖池比例派奖,无一等奖滚存累积。', + 'enabled' => false, + 'params' => [ + // ── 开奖时间 ── + 'draw_hour' => 20, // 每天几点开奖(24小时制) + 'draw_minute' => 0, // 几分开奖 + 'stop_sell_minutes' => 2, // 开奖前几分钟停止购票 + + // ── 购票限制 ── + 'ticket_price' => 100, // 每注金币 + 'max_tickets_per_user' => 50, // 每期单人最多购票注数 + 'max_tickets_per_buy' => 10, // 单次最多购买注数 + + // ── 奖池分配比例(%)── + 'pool_ratio' => 70, // 购票金额进奖池比例 + 'prize_1st_ratio' => 60, // 一等奖占奖池% + 'prize_2nd_ratio' => 20, // 二等奖占奖池% + 'prize_3rd_ratio' => 10, // 三等奖占奖池% + 'carry_ratio' => 10, // 强制滚存比例% + + // ── 固定小奖 ── + 'prize_4th_fixed' => 150, // 四等奖固定金额/注 + 'prize_5th_fixed' => 50, // 五等奖固定金额/注 + + // ── 超级期 ── + 'super_issue_threshold' => 3, // 连续几期无一等奖触发超级期 + 'super_issue_inject' => 20000, // 超级期系统注入金额上限 + ], + ], ]; foreach ($games as $game) { diff --git a/routes/console.php b/routes/console.php index b41c39e..e0f637b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -127,3 +127,44 @@ Schedule::call(function () { \App\Jobs\OpenHorseRaceJob::dispatch()->delay(now()->addSeconds(30)); })->everyMinute()->name('horse-race:open-race')->withoutOverlapping(); + +// ──────────── 双色球彩票定时任务 ───────────────────────────────── + +// 每分钟:检查是否到开奖时间,到期触发开奖;同时确保有进行中的期次 +Schedule::call(function () { + if (! \App\Models\GameConfig::isEnabled('lottery')) { + return; + } + + $issue = \App\Models\LotteryIssue::query()->whereIn('status', ['open', 'closed'])->latest()->first(); + + // 无进行中期次则自动创建一期 + if (! $issue) { + \App\Jobs\OpenLotteryIssueJob::dispatch(); + + return; + } + + // open 状态:检查是否已到停售时间 + if ($issue->status === 'open' && $issue->sell_closes_at && now()->gte($issue->sell_closes_at)) { + $issue->update(['status' => 'closed']); + $issue->refresh(); + } + + // closed 状态:检查是否已到开奖时间 + if ($issue->status === 'closed' && $issue->draw_at && now()->gte($issue->draw_at)) { + \App\Jobs\DrawLotteryJob::dispatch($issue); + } +})->everyMinute()->name('lottery:check')->withoutOverlapping(); + +// 每日 18:00:超级期预热广播(若当前期次为超级期,提醒用户购票) +Schedule::call(function () { + if (! \App\Models\GameConfig::isEnabled('lottery')) { + return; + } + + $issue = \App\Models\LotteryIssue::currentIssue(); + if ($issue && $issue->is_super_issue) { + \App\Jobs\OpenLotteryIssueJob::dispatch(); // 触发广播 + } +})->dailyAt('18:00')->name('lottery:super-reminder'); diff --git a/routes/web.php b/routes/web.php index 7203763..6b06cb6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -162,6 +162,20 @@ Route::middleware(['chat.auth'])->group(function () { Route::get('/history', [\App\Http\Controllers\FortuneTellingController::class, 'history'])->name('history'); }); + // ── 双色球彩票(前台)────────────────────────────────────── + Route::prefix('lottery')->name('lottery.')->group(function () { + // 当期状态:奖池金额 / 剩余购票时间 / 我的购票 + Route::get('/current', [\App\Http\Controllers\LotteryController::class, 'current'])->name('current'); + // 购票(自选或机选) + Route::post('/buy', [\App\Http\Controllers\LotteryController::class, 'buy'])->name('buy'); + // 服务端机选号码生成(不扣费,俩前端展示后确认购买) + Route::get('/quick-pick', [\App\Http\Controllers\LotteryController::class, 'quickPick'])->name('quick-pick'); + // 历史期次(最近20期) + Route::get('/history', [\App\Http\Controllers\LotteryController::class, 'history'])->name('history'); + // 我的购票记录 + Route::get('/my', [\App\Http\Controllers\LotteryController::class, 'my'])->name('my'); + }); + // ── 游戏大厅:实时开关状态接口 ──────────────────────────────────── Route::get('/games/enabled', function () { return response()->json([ @@ -171,6 +185,7 @@ Route::middleware(['chat.auth'])->group(function () { 'horse_racing' => \App\Models\GameConfig::isEnabled('horse_racing'), 'fortune_telling' => \App\Models\GameConfig::isEnabled('fortune_telling'), 'fishing' => \App\Models\GameConfig::isEnabled('fishing'), + 'lottery' => \App\Models\GameConfig::isEnabled('lottery'), ]); })->name('games.enabled');