[游戏管理]游戏实时对局

This commit is contained in:
2026-04-16 15:10:12 +08:00
parent 15fdd3ba57
commit c7149e7058
29 changed files with 1158 additions and 157 deletions

View File

@@ -2,7 +2,9 @@
namespace app\common\model;
use app\common\service\GameLiveService;
use support\think\Model;
use Throwable;
class BetOrder extends Model
{
@@ -34,9 +36,18 @@ class BetOrder extends Model
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function gamePeriod(): \think\model\relation\BelongsTo
protected static function onAfterInsert($model): void
{
return $this->belongsTo(GamePeriod::class, 'period_id', 'id');
try {
$periodId = isset($model['period_id']) ? (int) $model['period_id'] : null;
GameLiveService::publishSnapshot($periodId);
} catch (Throwable) {
}
}
public function gameRecord(): \think\model\relation\BelongsTo
{
return $this->belongsTo(GameRecord::class, 'period_id', 'id');
}
}

View File

@@ -6,7 +6,7 @@ use support\think\Model;
class GamePeriod extends Model
{
protected $name = 'game_period';
protected $name = 'game_record';
protected $autoWriteTimestamp = true;

View File

@@ -0,0 +1,39 @@
<?php
namespace app\common\model;
use support\think\Model;
class GameRecord extends Model
{
protected $name = 'game_record';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
];
public function setPeriodStartAtAttr($value, $data = [])
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
$t = strtotime($value);
if ($t !== false) {
return $t;
}
}
return 0;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
final class GameLiveService
{
private const BASE_ODDS = 33;
private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated';
public static function buildSnapshot(?int $recordId = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return [
'record' => null,
'bets' => [],
'candidate_numbers' => [],
'ai_default_number' => null,
'server_time' => time(),
];
}
$bets = Db::name('bet_order')
->where('period_id', (int) $record['id'])
->order('id', 'desc')
->limit(200)
->select()
->toArray();
$candidates = [];
$bestNumber = null;
$bestLoss = null;
for ($n = 1; $n <= 36; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
$candidates[] = [
'number' => $n,
'estimated_loss' => $loss,
];
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
$bestLoss = $loss;
$bestNumber = $n;
}
}
return [
'record' => $record,
'bets' => array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'user_id' => (int) $row['user_id'],
'period_no' => (string) $row['period_no'],
'pick_numbers' => $row['pick_numbers'],
'unit_amount' => (string) $row['unit_amount'],
'total_amount' => (string) $row['total_amount'],
'streak_at_bet' => (int) $row['streak_at_bet'],
'create_time' => (int) $row['create_time'],
];
}, $bets),
'candidate_numbers' => $candidates,
'ai_default_number' => $bestNumber,
'server_time' => time(),
];
}
public static function publishSnapshot(?int $recordId = null): void
{
try {
$payload = self::buildSnapshot($recordId);
$api = new Api(
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
(string) config('plugin.webman.push.app.app_key'),
(string) config('plugin.webman.push.app.app_secret')
);
$api->trigger(self::CHANNEL, self::EVENT, $payload);
} catch (Throwable) {
}
}
private static function resolveRecord(?int $recordId): ?array
{
if ($recordId !== null && $recordId > 0) {
$row = Db::name('game_record')->where('id', $recordId)->find();
if ($row) {
return $row;
}
}
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
}
private static function estimateLossForNumber(array $bets, int $number): string
{
$payout = '0.0000';
foreach ($bets as $bet) {
$pickNumbers = $bet['pick_numbers'];
if (is_string($pickNumbers)) {
$decoded = json_decode($pickNumbers, true);
$pickNumbers = is_array($decoded) ? $decoded : [];
}
if (!is_array($pickNumbers)) {
$pickNumbers = [];
}
if (!in_array($number, array_map('intval', $pickNumbers), true)) {
continue;
}
$unit = (string) ($bet['unit_amount'] ?? '0');
$streak = (int) ($bet['streak_at_bet'] ?? 0);
$odds = (string) (($streak + 1) * self::BASE_ODDS);
$orderPayout = bcmul($unit, $odds, 4);
$payout = bcadd($payout, $orderPayout, 4);
}
return $payout;
}
}

View File

@@ -4,145 +4,41 @@ declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
/**
* 全局期号:创建与 game_config 开关(自动新建下一期)
* 兼容层:保留旧类名,内部转发到 GameRecordService
*/
final class GamePeriodService
{
public const KEY_AUTO_CREATE = 'period_auto_create_enabled';
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
/** 进行中状态:未结束则不可再开新期 */
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public const KEY_AUTO_CREATE = GameRecordService::KEY_AUTO_CREATE;
public const KEY_MANUAL_CREATE = GameRecordService::KEY_MANUAL_CREATE;
public static function getConfigBool(string $key): bool
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return false;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
return GameRecordService::getConfigBool($key);
}
/**
* @return array{period_auto_create_enabled: int, period_manual_create_enabled: int}
*/
public static function getPeriodSettings(): array
{
return [
'period_auto_create_enabled' => self::getConfigBool(self::KEY_AUTO_CREATE) ? 1 : 0,
'period_manual_create_enabled' => self::getConfigBool(self::KEY_MANUAL_CREATE) ? 1 : 0,
];
return GameRecordService::getRecordSettings();
}
/**
* @param array{period_auto_create_enabled?: int|string, period_manual_create_enabled?: int|string} $data
*/
public static function savePeriodSettings(array $data): void
{
$now = time();
$auto = self::truthyConfigInput($data['period_auto_create_enabled'] ?? null) ? '1' : '0';
$manual = self::truthyConfigInput($data['period_manual_create_enabled'] ?? null) ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $auto, 'int', '是否允许定时任务自动创建下一期(全局仅一局)', $now);
self::upsertConfig(self::KEY_MANUAL_CREATE, $manual, 'int', '是否允许后台手动创建下一期', $now);
GameRecordService::saveRecordSettings($data);
}
public static function hasActivePeriod(): bool
{
$count = Db::name('game_period')->whereIn('status', self::ACTIVE_STATUSES)->count();
return $count > 0;
return GameRecordService::hasActiveRecord();
}
/**
* 自动开奖任务调用:开启自动创建且无进行中期号时插入新期
*/
public static function tickAutoCreate(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActivePeriod()) {
return;
}
try {
self::createNextPeriodRow();
} catch (Throwable) {
// 并发下可能重复,忽略
}
GameRecordService::tickAutoCreate();
}
/**
* @return array{ok: bool, msg: string, period_no?: string}
*/
public static function createNextPeriodForManual(): array
{
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
return ['ok' => false, 'msg' => '未开启「手动创建下一期」开关'];
}
if (self::hasActivePeriod()) {
return ['ok' => false, 'msg' => '存在未结束期号,无法新建'];
}
try {
$periodNo = self::createNextPeriodRow();
return ['ok' => true, 'msg' => '已创建新期', 'period_no' => $periodNo];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
/**
* @throws Throwable
*/
private static function createNextPeriodRow(): string
{
$periodNo = self::generatePeriodNo();
$now = time();
Db::name('game_period')->insert([
'period_no' => $periodNo,
'period_start_at' => $now,
'status' => 0,
'draw_mode' => 0,
'void_reason' => '',
'create_time' => $now,
'update_time' => $now,
]);
return $periodNo;
}
private static function generatePeriodNo(): string
{
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}
private static function truthyConfigInput(mixed $v): bool
{
return $v === 1 || $v === '1' || $v === true;
}
private static function upsertConfig(string $key, string $value, string $valueType, string $remark, int $now): void
{
$exists = Db::name('game_config')->where('config_key', $key)->find();
if ($exists) {
Db::name('game_config')->where('config_key', $key)->update([
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'update_time' => $now,
]);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
return GameRecordService::createNextRecordForManual();
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
final class GameRecordService
{
public const KEY_AUTO_CREATE = 'period_auto_create_enabled';
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public static function getConfigBool(string $key): bool
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return false;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
}
public static function getRecordSettings(): array
{
return [
'period_auto_create_enabled' => self::getConfigBool(self::KEY_AUTO_CREATE) ? 1 : 0,
'period_manual_create_enabled' => self::getConfigBool(self::KEY_MANUAL_CREATE) ? 1 : 0,
];
}
public static function saveRecordSettings(array $data): void
{
$now = time();
$auto = self::truthyConfigInput($data['period_auto_create_enabled'] ?? null) ? '1' : '0';
$manual = self::truthyConfigInput($data['period_manual_create_enabled'] ?? null) ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $auto, 'int', '是否允许定时任务自动创建下一局(全局仅一局)', $now);
self::upsertConfig(self::KEY_MANUAL_CREATE, $manual, 'int', '是否允许后台手动创建下一局', $now);
}
public static function hasActiveRecord(): bool
{
$count = Db::name('game_record')->whereIn('status', self::ACTIVE_STATUSES)->count();
return $count > 0;
}
public static function tickAutoCreate(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActiveRecord()) {
return;
}
try {
self::createNextRecordRow();
} catch (Throwable) {
}
}
public static function createNextRecordForManual(): array
{
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
return ['ok' => false, 'msg' => '未开启「手动创建下一局」开关'];
}
if (self::hasActiveRecord()) {
return ['ok' => false, 'msg' => '存在未结束对局,无法新建'];
}
try {
$periodNo = self::createNextRecordRow();
return ['ok' => true, 'msg' => '已创建新对局', 'period_no' => $periodNo];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
private static function createNextRecordRow(): string
{
$periodNo = self::generatePeriodNo();
$now = time();
Db::name('game_record')->insert([
'period_no' => $periodNo,
'period_start_at' => $now,
'status' => 0,
'draw_mode' => 0,
'void_reason' => '',
'create_time' => $now,
'update_time' => $now,
]);
return $periodNo;
}
private static function generatePeriodNo(): string
{
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}
private static function truthyConfigInput(mixed $v): bool
{
return $v === 1 || $v === '1' || $v === true;
}
private static function upsertConfig(string $key, string $value, string $valueType, string $remark, int $now): void
{
$exists = Db::name('game_config')->where('config_key', $key)->find();
if ($exists) {
Db::name('game_config')->where('config_key', $key)->update([
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'update_time' => $now,
]);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
}
}

View File

@@ -11,7 +11,7 @@ class GamePeriod extends Validate
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_period',
'period_no' => 'require|max:64|unique:game_record',
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class GameRecord extends Validate
{
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_record',
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',
'preset_number' => 'between:1,36',
'result_number' => 'between:1,36',
];
protected $scene = [
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
];
}