新增:双色球彩票系统后端基础(阶段一)
📦 数据库 - 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:
@@ -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 => '双色球中奖',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
180
app/Http/Controllers/LotteryController.php
Normal file
180
app/Http/Controllers/LotteryController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
58
app/Jobs/DrawLotteryJob.php
Normal file
58
app/Jobs/DrawLotteryJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
app/Jobs/OpenLotteryIssueJob.php
Normal file
69
app/Jobs/OpenLotteryIssueJob.php
Normal 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
164
app/Models/LotteryIssue.php
Normal 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 => '未中奖',
|
||||
};
|
||||
}
|
||||
}
|
||||
72
app/Models/LotteryPoolLog.php
Normal file
72
app/Models/LotteryPoolLog.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
83
app/Models/LotteryTicket.php
Normal file
83
app/Models/LotteryTicket.php
Normal 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;
|
||||
}
|
||||
}
|
||||
477
app/Services/LotteryService.php
Normal file
477
app/Services/LotteryService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
14
app/app/Services/LotteryService.php
Normal file
14
app/app/Services/LotteryService.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\app\Services;
|
||||
|
||||
class LotteryService
|
||||
{
|
||||
/**
|
||||
* Create a new class instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user