新增:双色球彩票系统后端基础(阶段一)

📦 数据库
  - lottery_issues(期次表)
  - lottery_tickets(购票记录表)
  - lottery_pool_logs(奖池流水表,透明展示)

🔩 核心组件
  - LotteryIssue / LotteryTicket / LotteryPoolLog 完整 Model
  - LotteryService:购票/机选/开奖/奖池派发/滚存/超级期预热/公屏广播
  - LotteryController:current/buy/quickPick/history/my 五个接口
  - DrawLotteryJob(每日定时开奖)/ OpenLotteryIssueJob(初始化首期)

💰 货币日志
  - CurrencySource 新增 LOTTERY_BUY / LOTTERY_WIN
  - 所有金币变动均通过 UserCurrencyService::change() 记录流水

🗓️ 调度器
  - 每分钟检查停售/开奖时机
  - 每日 18:00 超级期预热广播

🔧 配置
  - GameConfigSeeder 追加 lottery 默认配置(默认关闭)
  - /games/enabled 接口追加 lottery 开关状态
  - 新增 /lottery/* 路由组(auth 保护)
This commit is contained in:
2026-03-04 15:38:02 +08:00
parent b30be5c053
commit 27371fe321
15 changed files with 1394 additions and 0 deletions

View File

@@ -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 => '双色球中奖',
};
}
}

View File

@@ -0,0 +1,180 @@
<?php
/**
* 文件功能:双色球彩票 HTTP 控制器
*
* 提供前端所需的四个 API 接口:
* - current() : 当期状态 + 奖池 + 我的购票列表
* - buy() : 购买一注或多注(支持机选)
* - history() : 历史期次列表
* - my() : 我的全部购票记录
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use App\Models\LotteryTicket;
use App\Services\LotteryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LotteryController extends Controller
{
public function __construct(
private readonly LotteryService $lottery,
) {}
/**
* 返回当期状态:期号、奖池、剩余时间、我本期购票列表。
*/
public function current(): JsonResponse
{
if (! GameConfig::isEnabled('lottery')) {
return response()->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]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:双色球开奖队列任务
*
* 由调度器在每日指定时间draw_hour:draw_minute触发。
* 流程:关闭本期购票 调用 LotteryService::draw() 执行开奖。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Models\LotteryIssue;
use App\Services\LotteryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class DrawLotteryJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 2;
/**
* @param LotteryIssue $issue 要开奖的期次
*/
public function __construct(
public readonly LotteryIssue $issue,
) {}
/**
* 执行开奖流程。
*/
public function handle(LotteryService $lottery): void
{
$issue = $this->issue->fresh();
// 防止重复开奖
if (! $issue || $issue->status === 'settled') {
return;
}
// 标记为已停售closed阻止新购票
if ($issue->status === 'open') {
$issue->update(['status' => 'closed']);
$issue->refresh();
}
// 执行开奖
$lottery->draw($issue);
}
}

View File

@@ -0,0 +1,69 @@
<?php
/**
* 文件功能:双色球开新期队列任务
*
* 在系统初始化时或管理员手动触发时使用,
* 创建第一期并设置好截止/开奖时间。
* 正常情况下,开奖后由 LotteryService::openNextIssue() 自动创建下一期。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Jobs;
use App\Models\GameConfig;
use App\Models\LotteryIssue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class OpenLotteryIssueJob implements ShouldQueue
{
use Queueable;
/**
* 最大重试次数。
*/
public int $tries = 1;
/**
* 执行开新期逻辑(仅在无当前期时创建)。
*/
public function handle(): void
{
if (! GameConfig::isEnabled('lottery')) {
return;
}
// 已有进行中的期次则跳过
if (LotteryIssue::currentIssue()) {
return;
}
$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()->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,
]);
}
}

164
app/Models/LotteryIssue.php Normal file
View File

@@ -0,0 +1,164 @@
<?php
/**
* 文件功能:双色球期次 Model
*
* 管理每期彩票的生命周期open购票中 closed停售 settled已开奖
* 提供静态查询方法、奖级判定辅助及号码排序工具。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LotteryIssue extends Model
{
protected $fillable = [
'issue_no',
'status',
'red1', 'red2', 'red3', 'blue',
'pool_amount',
'carry_amount',
'is_super_issue',
'no_winner_streak',
'total_tickets',
'payout_amount',
'sell_closes_at',
'draw_at',
];
/**
* 字段类型转换。
*/
protected function casts(): array
{
return [
'is_super_issue' => '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 => '未中奖',
};
}
}

View File

@@ -0,0 +1,72 @@
<?php
/**
* 文件功能:双色球奖池流水 Model
*
* 记录每期奖池的每笔变动,提供透明的奖池历史查询。
* reason 取值ticket_sale / carry_over / admin_inject / system_inject / payout / prize_4th / prize_5th
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LotteryPoolLog extends Model
{
public $timestamps = false;
protected $fillable = [
'issue_id',
'change_amount',
'reason',
'pool_after',
'remark',
'created_at',
];
/**
* 字段类型转换。
*/
protected function casts(): array
{
return [
'change_amount' => '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,
};
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* 文件功能:双色球购票记录 Model
*
* 每条记录对应一注彩票(一组选号),包含开奖后的中奖等级与派奖金额。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LotteryTicket extends Model
{
protected $fillable = [
'issue_id',
'user_id',
'red1', 'red2', 'red3', 'blue',
'amount',
'is_quick_pick',
'prize_level',
'payout',
];
/**
* 字段类型转换。
*/
protected function casts(): array
{
return [
'is_quick_pick' => '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;
}
}

View File

@@ -0,0 +1,477 @@
<?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) {
$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<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);
}
/**
* 超级期预热广播。
*/
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);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\app\Services;
class LotteryService
{
/**
* Create a new class instance.
*/
public function __construct()
{
//
}
}

View File

@@ -0,0 +1,67 @@
<?php
/**
* 文件功能:双色球期次表迁移
*
* 记录每期彩票的开奖状态、号码、奖池金额、派奖结果。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 lottery_issues 表。
*/
public function up(): void
{
Schema::create('lottery_issues', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,53 @@
<?php
/**
* 文件功能:双色球奖池流水表迁移
*
* 透明记录每期奖池的每笔变动(售票入池、派奖扣除、滚存、系统注入)。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 lottery_pool_logs 表。
*/
public function up(): void
{
Schema::create('lottery_pool_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,58 @@
<?php
/**
* 文件功能:双色球购票记录表迁移
*
* 记录每用户每注的选号、中奖等级、派奖金额。
*
* @author ChatRoom Laravel
* @version 1.0.0
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 创建 lottery_tickets 表。
*/
public function up(): void
{
Schema::create('lottery_tickets', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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');