1.优化后台测试推送功能页面
2.优化开奖和实时对局页面
This commit is contained in:
@@ -76,6 +76,9 @@ class Live extends Backend
|
|||||||
return $this->success((string) $res['msg'], $res);
|
return $this->success((string) $res['msg'], $res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预约本期开奖号码(倒计时结束后自动开奖,不立即开奖)。
|
||||||
|
*/
|
||||||
public function draw(WebmanRequest $request): Response
|
public function draw(WebmanRequest $request): Response
|
||||||
{
|
{
|
||||||
$response = $this->initializeBackend($request);
|
$response = $this->initializeBackend($request);
|
||||||
@@ -88,10 +91,13 @@ class Live extends Backend
|
|||||||
$recordIdRaw = $request->post('record_id');
|
$recordIdRaw = $request->post('record_id');
|
||||||
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
|
||||||
$manualRaw = $request->post('manual_number');
|
$manualRaw = $request->post('manual_number');
|
||||||
$manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null;
|
if (!is_numeric((string) $manualRaw)) {
|
||||||
$res = GameLiveService::drawResult($recordId, $manualNumber);
|
return $this->error('请填写开奖号码');
|
||||||
|
}
|
||||||
|
$manualNumber = (int) $manualRaw;
|
||||||
|
$res = GameLiveService::scheduleDraw($recordId, $manualNumber);
|
||||||
if (!($res['ok'] ?? false)) {
|
if (!($res['ok'] ?? false)) {
|
||||||
return $this->error((string) ($res['msg'] ?? '开奖失败'));
|
return $this->error((string) ($res['msg'] ?? '预约失败'));
|
||||||
}
|
}
|
||||||
return $this->success((string) $res['msg'], $res);
|
return $this->success((string) $res['msg'], $res);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use app\common\model\BetOrder;
|
|||||||
use app\common\model\GameConfig;
|
use app\common\model\GameConfig;
|
||||||
use app\common\model\GameRecord;
|
use app\common\model\GameRecord;
|
||||||
use app\common\model\UserWalletRecord;
|
use app\common\model\UserWalletRecord;
|
||||||
|
use app\common\service\UserPushService;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use Webman\Http\Request;
|
use Webman\Http\Request;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
@@ -217,6 +218,14 @@ class Game extends MobileBase
|
|||||||
'update_time' => time(),
|
'update_time' => time(),
|
||||||
]);
|
]);
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [
|
||||||
|
'order_no' => $orderNo,
|
||||||
|
'period_no' => (string) $period->period_no,
|
||||||
|
'status' => 'accepted',
|
||||||
|
'balance_after' => $after,
|
||||||
|
'total_amount' => $totalAmount,
|
||||||
|
'current_streak' => (int) ($user->current_streak ?? 0),
|
||||||
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
|
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class GameRecord extends Model
|
|||||||
'draw_mode' => 'integer',
|
'draw_mode' => 'integer',
|
||||||
'preset_number' => 'integer',
|
'preset_number' => 'integer',
|
||||||
'result_number' => 'integer',
|
'result_number' => 'integer',
|
||||||
|
'ai_locked_number' => 'integer',
|
||||||
|
'pending_draw_number' => 'integer',
|
||||||
|
'payout_until' => 'integer',
|
||||||
'platform_profit_amount' => 'string',
|
'platform_profit_amount' => 'string',
|
||||||
'winner_user_count' => 'integer',
|
'winner_user_count' => 'integer',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ final class GameBetSettleService
|
|||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
|
/** @var array<int, array{period_no: string, total_win: string, balance_after: string, orders: list<array{order_no: string, win_amount: string, hit: bool}>}> */
|
||||||
|
$aggregateByUser = [];
|
||||||
|
|
||||||
foreach ($bets as $bet) {
|
foreach ($bets as $bet) {
|
||||||
$betId = (int) ($bet['id'] ?? 0);
|
$betId = (int) ($bet['id'] ?? 0);
|
||||||
if ($betId <= 0) {
|
if ($betId <= 0) {
|
||||||
@@ -59,11 +62,62 @@ final class GameBetSettleService
|
|||||||
// 结算刚刚成功(status 1 → 2):把本单下注总额 1:1 累加到用户打码量
|
// 结算刚刚成功(status 1 → 2):把本单下注总额 1:1 累加到用户打码量
|
||||||
self::creditUserBetFlow($bet, $now);
|
self::creditUserBetFlow($bet, $now);
|
||||||
|
|
||||||
if (bccomp($win, '0', 4) <= 0) {
|
$userId = (int) ($bet['user_id'] ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::creditUserPayout($bet, $betId, $win, $now);
|
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
|
||||||
|
if (bccomp($win, '0', 4) > 0) {
|
||||||
|
$paid = self::creditUserPayout($bet, $betId, $win, $now);
|
||||||
|
if ($paid !== null) {
|
||||||
|
$balanceAfter = $paid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$periodNo = (string) ($bet['period_no'] ?? '');
|
||||||
|
if (!isset($aggregateByUser[$userId])) {
|
||||||
|
$aggregateByUser[$userId] = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'total_win' => '0.0000',
|
||||||
|
'balance_after' => $balanceAfter,
|
||||||
|
'orders' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 4);
|
||||||
|
$aggregateByUser[$userId]['balance_after'] = $balanceAfter;
|
||||||
|
$aggregateByUser[$userId]['orders'][] = [
|
||||||
|
'order_no' => (string) $betId,
|
||||||
|
'win_amount' => $win,
|
||||||
|
'hit' => bccomp($win, '0', 4) > 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($aggregateByUser as $userId => $agg) {
|
||||||
|
$hitOrderCount = 0;
|
||||||
|
foreach ($agg['orders'] as $o) {
|
||||||
|
if (($o['hit'] ?? false) === true) {
|
||||||
|
$hitOrderCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UserPushService::publish((int) $userId, UserPushService::EVT_BET_SETTLED, [
|
||||||
|
'period_no' => $agg['period_no'],
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
'total_win_amount' => $agg['total_win'],
|
||||||
|
'order_count' => count($agg['orders']),
|
||||||
|
'hit_order_count' => $hitOrderCount,
|
||||||
|
'balance_after' => $agg['balance_after'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (bccomp($agg['total_win'], '0', 4) > 0) {
|
||||||
|
UserPushService::publish((int) $userId, UserPushService::EVT_WALLET_CHANGED, [
|
||||||
|
'reason' => 'payout',
|
||||||
|
'ref_type' => 'game_period',
|
||||||
|
'ref_id' => (string) $recordId,
|
||||||
|
'delta' => $agg['total_win'],
|
||||||
|
'balance_after' => $agg['balance_after'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,21 +215,26 @@ final class GameBetSettleService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): void
|
/**
|
||||||
|
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
|
||||||
|
*/
|
||||||
|
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string
|
||||||
{
|
{
|
||||||
$userId = (int) ($bet['user_id'] ?? 0);
|
$userId = (int) ($bet['user_id'] ?? 0);
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$idem = 'payout_bet_' . $betId;
|
$idem = 'payout_bet_' . $betId;
|
||||||
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
|
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
|
||||||
return;
|
$coin = Db::name('user')->where('id', $userId)->value('coin');
|
||||||
|
|
||||||
|
return (string) ($coin ?? '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Db::name('user')->where('id', $userId)->find();
|
$user = Db::name('user')->where('id', $userId)->find();
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$before = (string) ($user['coin'] ?? '0');
|
$before = (string) ($user['coin'] ?? '0');
|
||||||
@@ -201,5 +260,7 @@ final class GameBetSettleService
|
|||||||
'coin' => $after,
|
'coin' => $after,
|
||||||
'update_time' => $now,
|
'update_time' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return $after;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class GameLiveService
|
|||||||
private const EVT_PERIOD_TICK = 'period.tick';
|
private const EVT_PERIOD_TICK = 'period.tick';
|
||||||
private const EVT_PERIOD_LOCKED = 'period.locked';
|
private const EVT_PERIOD_LOCKED = 'period.locked';
|
||||||
private const EVT_PERIOD_OPENED = 'period.opened';
|
private const EVT_PERIOD_OPENED = 'period.opened';
|
||||||
|
private const EVT_PERIOD_PAYOUT = 'period.payout';
|
||||||
private const KEY_PERIOD_SECONDS = 'period_seconds';
|
private const KEY_PERIOD_SECONDS = 'period_seconds';
|
||||||
private const KEY_BET_SECONDS = 'bet_seconds';
|
private const KEY_BET_SECONDS = 'bet_seconds';
|
||||||
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
|
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
|
||||||
@@ -26,26 +27,21 @@ final class GameLiveService
|
|||||||
/** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */
|
/** 开奖结果号码池:1 至此上限(与单注可选号码个数配置无关) */
|
||||||
private const DRAW_NUMBER_MAX = 36;
|
private const DRAW_NUMBER_MAX = 36;
|
||||||
|
|
||||||
|
/** 开奖后派彩展示宽限期(秒),之后再创建下一期 */
|
||||||
|
private const PAYOUT_GRACE_SECONDS = 3;
|
||||||
|
|
||||||
public static function buildSnapshot(?int $recordId = null): array
|
public static function buildSnapshot(?int $recordId = null): array
|
||||||
{
|
{
|
||||||
$record = self::resolveRecord($recordId);
|
$record = self::resolveRecord($recordId);
|
||||||
if (!$record) {
|
if (!$record) {
|
||||||
return [
|
return self::emptySnapshotPayload();
|
||||||
'record' => null,
|
}
|
||||||
'bets' => [],
|
|
||||||
'candidate_numbers' => [],
|
$rid = (int) $record['id'];
|
||||||
'ai_default_number' => null,
|
self::ensureAiLocked($rid);
|
||||||
'calc_number' => null,
|
$record = self::reloadRecord($rid);
|
||||||
'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30),
|
if (!$record) {
|
||||||
'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20),
|
return self::emptySnapshotPayload();
|
||||||
'pick_max_number_count' => self::getPickMaxNumberCount(),
|
|
||||||
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
|
||||||
'remaining_seconds' => 0,
|
|
||||||
'bet_remaining_seconds' => 0,
|
|
||||||
'can_calculate' => false,
|
|
||||||
'can_draw' => false,
|
|
||||||
'server_time' => time(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||||
@@ -54,19 +50,22 @@ final class GameLiveService
|
|||||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
$remaining = max(0, $periodSeconds - $elapsed);
|
$remaining = max(0, $periodSeconds - $elapsed);
|
||||||
$betRemaining = max(0, $betSeconds - $elapsed);
|
$betRemaining = max(0, $betSeconds - $elapsed);
|
||||||
|
$status = (int) $record['status'];
|
||||||
|
|
||||||
|
$payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0;
|
||||||
|
$payoutRemaining = 0;
|
||||||
|
if ($status === 3 && $payoutUntil > 0) {
|
||||||
|
$payoutRemaining = max(0, $payoutUntil - time());
|
||||||
|
}
|
||||||
|
|
||||||
$bets = Db::name('bet_order')
|
$bets = Db::name('bet_order')
|
||||||
->where('period_id', (int) $record['id'])
|
->where('period_id', $rid)
|
||||||
->order('id', 'desc')
|
->order('id', 'desc')
|
||||||
->limit(200)
|
->limit(200)
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
$candidates = [];
|
$candidates = [];
|
||||||
$bestNumber = null;
|
|
||||||
$bestLoss = null;
|
|
||||||
$bestNumbers = [];
|
|
||||||
$status = (int) $record['status'];
|
|
||||||
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
|
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
|
||||||
if ($canCalculate) {
|
if ($canCalculate) {
|
||||||
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
||||||
@@ -75,18 +74,28 @@ final class GameLiveService
|
|||||||
'number' => $n,
|
'number' => $n,
|
||||||
'estimated_loss' => $loss,
|
'estimated_loss' => $loss,
|
||||||
];
|
];
|
||||||
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
|
|
||||||
$bestLoss = $loss;
|
|
||||||
$bestNumbers = [$n];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) {
|
|
||||||
$bestNumbers[] = $n;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$bestNumber = self::pickRandomNumber($bestNumbers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$aiLocked = $record['ai_locked_number'] ?? null;
|
||||||
|
$aiDisplay = null;
|
||||||
|
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
|
||||||
|
$aiDisplay = (int) $aiLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingRaw = $record['pending_draw_number'] ?? null;
|
||||||
|
$pendingDraw = null;
|
||||||
|
if ($pendingRaw !== null && $pendingRaw !== '' && is_numeric((string) $pendingRaw)) {
|
||||||
|
$pd = (int) $pendingRaw;
|
||||||
|
if ($pd >= 1 && $pd <= self::DRAW_NUMBER_MAX) {
|
||||||
|
$pendingDraw = $pd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$canScheduleDraw = ($status === 0 || $status === 1)
|
||||||
|
&& $elapsed >= $betSeconds
|
||||||
|
&& $elapsed < $periodSeconds;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'record' => $record,
|
'record' => $record,
|
||||||
'bets' => array_map(static function (array $row): array {
|
'bets' => array_map(static function (array $row): array {
|
||||||
@@ -101,16 +110,47 @@ final class GameLiveService
|
|||||||
];
|
];
|
||||||
}, $bets),
|
}, $bets),
|
||||||
'candidate_numbers' => $candidates,
|
'candidate_numbers' => $candidates,
|
||||||
'ai_default_number' => $bestNumber,
|
'ai_default_number' => $aiDisplay,
|
||||||
'calc_number' => $bestNumber,
|
'calc_number' => $aiDisplay,
|
||||||
|
'pending_draw_number' => $pendingDraw,
|
||||||
'period_seconds' => $periodSeconds,
|
'period_seconds' => $periodSeconds,
|
||||||
'bet_seconds' => $betSeconds,
|
'bet_seconds' => $betSeconds,
|
||||||
'pick_max_number_count' => $pickMax,
|
'pick_max_number_count' => $pickMax,
|
||||||
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
||||||
'remaining_seconds' => $remaining,
|
'remaining_seconds' => $remaining,
|
||||||
'bet_remaining_seconds' => $betRemaining,
|
'bet_remaining_seconds' => $betRemaining,
|
||||||
|
'payout_remaining_seconds' => $payoutRemaining,
|
||||||
|
'is_payout_phase' => $status === 3,
|
||||||
'can_calculate' => $canCalculate,
|
'can_calculate' => $canCalculate,
|
||||||
'can_draw' => $canCalculate,
|
'can_draw' => $canScheduleDraw,
|
||||||
|
'can_schedule_draw' => $canScheduleDraw,
|
||||||
|
'server_time' => time(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function emptySnapshotPayload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'record' => null,
|
||||||
|
'bets' => [],
|
||||||
|
'candidate_numbers' => [],
|
||||||
|
'ai_default_number' => null,
|
||||||
|
'calc_number' => null,
|
||||||
|
'pending_draw_number' => null,
|
||||||
|
'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30),
|
||||||
|
'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20),
|
||||||
|
'pick_max_number_count' => self::getPickMaxNumberCount(),
|
||||||
|
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
||||||
|
'remaining_seconds' => 0,
|
||||||
|
'bet_remaining_seconds' => 0,
|
||||||
|
'payout_remaining_seconds' => 0,
|
||||||
|
'is_payout_phase' => false,
|
||||||
|
'can_calculate' => false,
|
||||||
|
'can_draw' => false,
|
||||||
|
'can_schedule_draw' => false,
|
||||||
'server_time' => time(),
|
'server_time' => time(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -130,12 +170,11 @@ final class GameLiveService
|
|||||||
if ($elapsed < $betSeconds) {
|
if ($elapsed < $betSeconds) {
|
||||||
return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算'];
|
return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算'];
|
||||||
}
|
}
|
||||||
if ((int) $record['status'] === 0) {
|
|
||||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
self::ensureAiLocked((int) $record['id']);
|
||||||
'status' => 1,
|
$record = self::reloadRecord((int) $record['id']);
|
||||||
'update_time' => time(),
|
if (!$record) {
|
||||||
]);
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
$record['status'] = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$pickMax = self::getPickMaxNumberCount();
|
$pickMax = self::getPickMaxNumberCount();
|
||||||
@@ -162,6 +201,12 @@ final class GameLiveService
|
|||||||
}
|
}
|
||||||
$bestNumber = self::pickRandomNumber($bestNumbers);
|
$bestNumber = self::pickRandomNumber($bestNumbers);
|
||||||
|
|
||||||
|
$aiLocked = $record['ai_locked_number'] ?? null;
|
||||||
|
$aiDisplay = null;
|
||||||
|
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
|
||||||
|
$aiDisplay = (int) $aiLocked;
|
||||||
|
}
|
||||||
|
|
||||||
$finalNumber = $manualNumber ?? $bestNumber;
|
$finalNumber = $manualNumber ?? $bestNumber;
|
||||||
$finalLoss = '0.0000';
|
$finalLoss = '0.0000';
|
||||||
if ($finalNumber !== null) {
|
if ($finalNumber !== null) {
|
||||||
@@ -177,31 +222,122 @@ final class GameLiveService
|
|||||||
'pick_max_number_count' => $pickMax,
|
'pick_max_number_count' => $pickMax,
|
||||||
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
'draw_number_max' => self::DRAW_NUMBER_MAX,
|
||||||
'candidate_numbers' => $candidates,
|
'candidate_numbers' => $candidates,
|
||||||
'ai_default_number' => $bestNumber,
|
'ai_default_number' => $aiDisplay,
|
||||||
'final_number' => $finalNumber,
|
'final_number' => $finalNumber,
|
||||||
'final_estimated_loss' => $finalLoss,
|
'final_estimated_loss' => $finalLoss,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员预约本期开奖号码(倒计时结束后由 tick 自动开奖,不立即开奖)。
|
||||||
|
*/
|
||||||
|
public static function scheduleDraw(?int $recordId, int $manualNumber): array
|
||||||
|
{
|
||||||
|
if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) {
|
||||||
|
return ['ok' => false, 'msg' => '开奖号码超出允许范围'];
|
||||||
|
}
|
||||||
|
$record = self::resolveRecord($recordId);
|
||||||
|
if (!$record) {
|
||||||
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
|
}
|
||||||
|
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||||
|
return ['ok' => false, 'msg' => '当前对局状态不可预约开奖'];
|
||||||
|
}
|
||||||
|
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||||
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
|
if ($elapsed < $betSeconds) {
|
||||||
|
return ['ok' => false, 'msg' => '下注尚未结束,无法预约开奖'];
|
||||||
|
}
|
||||||
|
if ($elapsed >= $periodSeconds) {
|
||||||
|
return ['ok' => false, 'msg' => '本期倒计时已结束,请刷新页面'];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::ensureAiLocked((int) $record['id']);
|
||||||
|
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
||||||
|
'pending_draw_number' => $manualNumber,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
self::publishSnapshot(null);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'msg' => '已预约本期开奖号码,倒计时结束后将使用该号码开奖',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 倒计时结束自动开奖(AI 或预约号码);派彩宽限期后由 finalizePayoutGrace 结单并开下一期。
|
||||||
|
*/
|
||||||
public static function drawResult(?int $recordId, ?int $manualNumber = null): array
|
public static function drawResult(?int $recordId, ?int $manualNumber = null): array
|
||||||
{
|
{
|
||||||
$calc = self::calculateResult($recordId, $manualNumber);
|
$record = self::resolveRecord($recordId);
|
||||||
if (!($calc['ok'] ?? false)) {
|
if (!$record) {
|
||||||
return $calc;
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
}
|
}
|
||||||
$record = $calc['record'];
|
if (!in_array((int) $record['status'], [0, 1], true)) {
|
||||||
$finalNumber = (int) $calc['final_number'];
|
return ['ok' => false, 'msg' => '当前对局状态不可开奖'];
|
||||||
|
}
|
||||||
|
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||||
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
|
if ($elapsed < $betSeconds) {
|
||||||
|
return ['ok' => false, 'msg' => '下注开放时长未结束,不可开奖'];
|
||||||
|
}
|
||||||
|
if ($elapsed < $periodSeconds) {
|
||||||
|
return ['ok' => false, 'msg' => '本期倒计时未结束,无法开奖'];
|
||||||
|
}
|
||||||
|
|
||||||
|
self::ensureAiLocked((int) $record['id']);
|
||||||
|
$record = self::reloadRecord((int) $record['id']);
|
||||||
|
if (!$record) {
|
||||||
|
return ['ok' => false, 'msg' => '未找到进行中的对局'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$useManual = $manualNumber;
|
||||||
|
if ($useManual === null) {
|
||||||
|
$p = $record['pending_draw_number'] ?? null;
|
||||||
|
if ($p !== null && $p !== '' && is_numeric((string) $p)) {
|
||||||
|
$pn = (int) $p;
|
||||||
|
if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) {
|
||||||
|
$useManual = $pn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalNumber = null;
|
||||||
|
$drawMode = 0;
|
||||||
|
if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) {
|
||||||
|
$finalNumber = $useManual;
|
||||||
|
$drawMode = 1;
|
||||||
|
} else {
|
||||||
|
$al = $record['ai_locked_number'] ?? null;
|
||||||
|
if ($al !== null && $al !== '' && is_numeric((string) $al)) {
|
||||||
|
$finalNumber = (int) $al;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($finalNumber === null || $finalNumber < 1) {
|
||||||
|
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||||
|
$finalNumber = self::computeBestNumberFromBets($bets) ?? 1;
|
||||||
|
$drawMode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
|
||||||
|
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
|
||||||
$now = time();
|
$now = time();
|
||||||
|
$payoutUntil = $now + self::PAYOUT_GRACE_SECONDS;
|
||||||
|
|
||||||
Db::startTrans();
|
Db::startTrans();
|
||||||
try {
|
try {
|
||||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
||||||
'status' => 4,
|
'status' => 3,
|
||||||
'result_number' => $finalNumber,
|
'result_number' => $finalNumber,
|
||||||
'draw_mode' => $manualNumber === null ? 0 : 1,
|
'draw_mode' => $drawMode,
|
||||||
|
'pending_draw_number' => null,
|
||||||
|
'payout_until' => $payoutUntil,
|
||||||
'update_time' => $now,
|
'update_time' => $now,
|
||||||
]);
|
]);
|
||||||
GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
||||||
GameRecordService::createNextRecordAfterDraw();
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -210,16 +346,50 @@ final class GameLiveService
|
|||||||
}
|
}
|
||||||
|
|
||||||
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
||||||
|
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
|
||||||
self::publishSnapshot(null);
|
self::publishSnapshot(null);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'msg' => '开奖完成',
|
'msg' => '开奖完成,派彩中',
|
||||||
'result_number' => $finalNumber,
|
'result_number' => $finalNumber,
|
||||||
'estimated_loss' => $calc['final_estimated_loss'],
|
'estimated_loss' => $finalLoss,
|
||||||
|
'payout_until' => $payoutUntil,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派彩宽限期结束:将本期置为已结束并创建下一期。
|
||||||
|
*/
|
||||||
|
public static function finalizePayoutGrace(): void
|
||||||
|
{
|
||||||
|
$row = Db::name('game_record')
|
||||||
|
->where('status', 3)
|
||||||
|
->where('payout_until', '>', 0)
|
||||||
|
->where('payout_until', '<=', time())
|
||||||
|
->order('id', 'desc')
|
||||||
|
->find();
|
||||||
|
if (!$row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$id = (int) $row['id'];
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
Db::name('game_record')->where('id', $id)->update([
|
||||||
|
'status' => 4,
|
||||||
|
'payout_until' => null,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
GameRecordService::createNextRecordAfterDraw();
|
||||||
|
Db::commit();
|
||||||
|
} catch (Throwable) {
|
||||||
|
Db::rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GameRecordStatService::refreshForRecordId($id);
|
||||||
|
self::publishSnapshot(null);
|
||||||
|
}
|
||||||
|
|
||||||
public static function tickAutoDraw(): void
|
public static function tickAutoDraw(): void
|
||||||
{
|
{
|
||||||
$record = self::resolveRecord(null);
|
$record = self::resolveRecord(null);
|
||||||
@@ -229,14 +399,12 @@ final class GameLiveService
|
|||||||
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||||
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
if ($elapsed >= $betSeconds && (int) $record['status'] === 0) {
|
self::ensureAiLocked((int) $record['id']);
|
||||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
$record = self::reloadRecord((int) $record['id']);
|
||||||
'status' => 1,
|
if (!$record) {
|
||||||
'update_time' => time(),
|
return;
|
||||||
]);
|
|
||||||
$record['status'] = 1;
|
|
||||||
self::publishPublicPeriodLocked($record);
|
|
||||||
}
|
}
|
||||||
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
if ($elapsed < $periodSeconds) {
|
if ($elapsed < $periodSeconds) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -272,6 +440,8 @@ final class GameLiveService
|
|||||||
$serverTime = (int) ($snapshot['server_time'] ?? time());
|
$serverTime = (int) ($snapshot['server_time'] ?? time());
|
||||||
$remaining = (int) ($snapshot['remaining_seconds'] ?? 0);
|
$remaining = (int) ($snapshot['remaining_seconds'] ?? 0);
|
||||||
$betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0);
|
$betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0);
|
||||||
|
$payoutRem = (int) ($snapshot['payout_remaining_seconds'] ?? 0);
|
||||||
|
$isPayout = !empty($snapshot['is_payout_phase']);
|
||||||
$periodNo = '';
|
$periodNo = '';
|
||||||
$dbStatus = 0;
|
$dbStatus = 0;
|
||||||
$resultNumber = null;
|
$resultNumber = null;
|
||||||
@@ -292,6 +462,9 @@ final class GameLiveService
|
|||||||
'status' => $status,
|
'status' => $status,
|
||||||
'countdown' => $remaining,
|
'countdown' => $remaining,
|
||||||
'bet_close_in'=> $betCloseIn,
|
'bet_close_in'=> $betCloseIn,
|
||||||
|
'payout_remaining_seconds' => $payoutRem,
|
||||||
|
'is_payout_phase' => $isPayout,
|
||||||
|
'payout_message' => $isPayout ? '派彩中,请稍候' : '',
|
||||||
];
|
];
|
||||||
if ($periodNo !== '' && $record !== null) {
|
if ($periodNo !== '' && $record !== null) {
|
||||||
$start = (int) ($record['period_start_at'] ?? 0);
|
$start = (int) ($record['period_start_at'] ?? 0);
|
||||||
@@ -341,6 +514,24 @@ final class GameLiveService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派彩阶段开始(开奖后宽限期内推送)
|
||||||
|
*/
|
||||||
|
private static function publishPublicPeriodPayout(string $periodNo, int $resultNumber, int $payoutUntil): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = [
|
||||||
|
'period_no' => $periodNo,
|
||||||
|
'result_number' => $resultNumber,
|
||||||
|
'payout_until' => $payoutUntil,
|
||||||
|
'message' => '派彩中,请稍候',
|
||||||
|
];
|
||||||
|
$api = self::createPushApi();
|
||||||
|
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_PAYOUT, $payload);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished
|
* 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished
|
||||||
*/
|
*/
|
||||||
@@ -355,7 +546,10 @@ final class GameLiveService
|
|||||||
if ($dbStatus === 4) {
|
if ($dbStatus === 4) {
|
||||||
return 'finished';
|
return 'finished';
|
||||||
}
|
}
|
||||||
if ($dbStatus === 2 || $dbStatus === 3) {
|
if ($dbStatus === 3) {
|
||||||
|
return 'payouting';
|
||||||
|
}
|
||||||
|
if ($dbStatus === 2) {
|
||||||
return 'settling';
|
return 'settling';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +567,84 @@ final class GameLiveService
|
|||||||
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
|
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function reloadRecord(int $id): ?array
|
||||||
|
{
|
||||||
|
$row = Db::name('game_record')->where('id', $id)->find();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封盘后计算并锁定 AI 号码(本期不变),并封盘(status 0→1)。
|
||||||
|
*/
|
||||||
|
private static function ensureAiLocked(int $recordId): void
|
||||||
|
{
|
||||||
|
$record = Db::name('game_record')->where('id', $recordId)->find();
|
||||||
|
if (!$record) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
|
||||||
|
$elapsed = max(0, time() - (int) $record['period_start_at']);
|
||||||
|
if ($elapsed < $betSeconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$st = (int) $record['status'];
|
||||||
|
if ($st !== 0 && $st !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $record['ai_locked_number'] ?? null;
|
||||||
|
if ($existing !== null && $existing !== '' && is_numeric((string) $existing) && (int) $existing > 0) {
|
||||||
|
if ($st === 0) {
|
||||||
|
Db::name('game_record')->where('id', $recordId)->update([
|
||||||
|
'status' => 1,
|
||||||
|
'update_time' => time(),
|
||||||
|
]);
|
||||||
|
$record['status'] = 1;
|
||||||
|
self::publishPublicPeriodLocked($record);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray();
|
||||||
|
$best = self::computeBestNumberFromBets($bets);
|
||||||
|
if ($best === null || $best < 1) {
|
||||||
|
$best = 1;
|
||||||
|
}
|
||||||
|
$update = [
|
||||||
|
'ai_locked_number' => $best,
|
||||||
|
'update_time' => time(),
|
||||||
|
];
|
||||||
|
if ($st === 0) {
|
||||||
|
$update['status'] = 1;
|
||||||
|
}
|
||||||
|
Db::name('game_record')->where('id', $recordId)->update($update);
|
||||||
|
$record = array_merge($record, $update);
|
||||||
|
if ($st === 0) {
|
||||||
|
self::publishPublicPeriodLocked($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $bets
|
||||||
|
*/
|
||||||
|
private static function computeBestNumberFromBets(array $bets): ?int
|
||||||
|
{
|
||||||
|
$bestLoss = null;
|
||||||
|
$bestNumbers = [];
|
||||||
|
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
|
||||||
|
$loss = self::estimateLossForNumber($bets, $n);
|
||||||
|
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
|
||||||
|
$bestLoss = $loss;
|
||||||
|
$bestNumbers = [$n];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) {
|
||||||
|
$bestNumbers[] = $n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::pickRandomNumber($bestNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
private static function getConfigInt(string $key, int $default): int
|
private static function getConfigInt(string $key, int $default): int
|
||||||
{
|
{
|
||||||
$row = Db::name('game_config')->where('config_key', $key)->find();
|
$row = Db::name('game_config')->where('config_key', $key)->find();
|
||||||
|
|||||||
61
app/common/service/UserPushService.php
Normal file
61
app/common/service/UserPushService.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use support\think\Db;
|
||||||
|
use Throwable;
|
||||||
|
use Webman\Push\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户私有频道推送:private-user-{uuid}(与移动端接口设计草案 7.1 一致)
|
||||||
|
*/
|
||||||
|
final class UserPushService
|
||||||
|
{
|
||||||
|
public const EVT_BET_ACCEPTED = 'bet.accepted';
|
||||||
|
|
||||||
|
/** 单注开奖结果(含未中奖 win_amount=0) */
|
||||||
|
public const EVT_BET_SETTLED = 'bet.settled';
|
||||||
|
|
||||||
|
public const EVT_WALLET_CHANGED = 'wallet.changed';
|
||||||
|
|
||||||
|
private static function channelName(string $uuid): string
|
||||||
|
{
|
||||||
|
return 'private-user-' . $uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function createApi(): Api
|
||||||
|
{
|
||||||
|
return 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function uuidForUserId(int $userId): ?string
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$u = Db::name('user')->where('id', $userId)->value('uuid');
|
||||||
|
|
||||||
|
return is_string($u) && $u !== '' ? $u : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function publish(int $userId, string $event, array $data): void
|
||||||
|
{
|
||||||
|
$uuid = self::uuidForUserId($userId);
|
||||||
|
if ($uuid === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
self::createApi()->trigger(self::channelName($uuid), $event, $data);
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class GameLiveTicker
|
|||||||
public function onWorkerStart(): void
|
public function onWorkerStart(): void
|
||||||
{
|
{
|
||||||
Timer::add(1, static function (): void {
|
Timer::add(1, static function (): void {
|
||||||
|
GameLiveService::finalizePayoutGrace();
|
||||||
GameLiveService::tickAutoDraw();
|
GameLiveService::tickAutoDraw();
|
||||||
GameLiveService::publishSnapshot(null);
|
GameLiveService::publishSnapshot(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: 'Listen to pushed bet stream in real time and show the AI default number (minimum estimated platform loss).',
|
tip: 'Realtime bets; after lock the AI default number is fixed for this round and used at countdown end (or your scheduled number). After draw, ~3s payout grace then next round.',
|
||||||
current_record: 'Current round',
|
current_record: 'Current round',
|
||||||
ai_default_number: 'AI default number',
|
ai_default_number: 'AI default number',
|
||||||
|
pending_draw: 'Scheduled draw number',
|
||||||
countdown: 'Countdown',
|
countdown: 'Countdown',
|
||||||
bet_countdown: 'Bet left',
|
bet_countdown: 'Bet left',
|
||||||
draw_countdown: 'Draw left',
|
draw_countdown: 'Draw left',
|
||||||
|
payout_countdown: 'Payout left',
|
||||||
|
payout_na: '—',
|
||||||
|
payout_phase: 'Payout in progress',
|
||||||
btn_calc: 'Calculate PnL',
|
btn_calc: 'Calculate PnL',
|
||||||
btn_draw: 'Draw now',
|
btn_draw: 'Schedule draw',
|
||||||
calc_result_number: 'Calculated number',
|
calc_result_number: 'Calculated number',
|
||||||
calc_estimated_loss: 'Estimated payout',
|
calc_estimated_loss: 'Estimated payout',
|
||||||
push_connected: 'Push connected, realtime updates running',
|
push_connected: 'Push connected, realtime updates running',
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: 'Subscribe to public-game-period (global period channel) for period.tick / period.locked / period.opened. The server must publish to this channel.',
|
tip: 'Subscribe to public-game-period (global period channel) for period.tick / period.locked / period.opened / period.payout. The server must publish to this channel.',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: '实时监听页面推送的压注记录,并展示AI默认最优开奖号码(平台预估亏损最少)',
|
tip: '实时监听压注记录;封盘后 AI 默认号码会锁定,本期倒计时结束按该号码(或您预约的号码)开奖;开奖后约 3 秒派彩再进入下一期。',
|
||||||
current_record: '当前对局',
|
current_record: '当前对局',
|
||||||
ai_default_number: 'AI默认开奖号码',
|
ai_default_number: 'AI默认开奖号码',
|
||||||
|
pending_draw: '已预约开奖号码',
|
||||||
countdown: '倒计时',
|
countdown: '倒计时',
|
||||||
bet_countdown: '下注剩余',
|
bet_countdown: '下注剩余',
|
||||||
draw_countdown: '开奖剩余',
|
draw_countdown: '开奖剩余',
|
||||||
|
payout_countdown: '派彩剩余',
|
||||||
|
payout_na: '—',
|
||||||
|
payout_phase: '派彩中,请稍候',
|
||||||
btn_calc: '计算法盈亏',
|
btn_calc: '计算法盈亏',
|
||||||
btn_draw: '开奖',
|
btn_draw: '预约开奖',
|
||||||
calc_result_number: '计算开奖号码',
|
calc_result_number: '计算开奖号码',
|
||||||
calc_estimated_loss: '计算预估赔付',
|
calc_estimated_loss: '计算预估赔付',
|
||||||
push_connected: '推送服务已连接,页面数据实时更新中',
|
push_connected: '推送服务已连接,页面数据实时更新中',
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened 等公共事件(需服务端向该频道推送)。',
|
tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened / period.payout 等公共事件(需服务端向该频道推送)。',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const DOC_EVENTS = [
|
|||||||
'period.tick',
|
'period.tick',
|
||||||
'period.locked',
|
'period.locked',
|
||||||
'period.opened',
|
'period.opened',
|
||||||
|
'period.payout',
|
||||||
'bet.accepted',
|
'bet.accepted',
|
||||||
|
'bet.settled',
|
||||||
'wallet.changed',
|
'wallet.changed',
|
||||||
'notice.popout',
|
'notice.popout',
|
||||||
'withdraw.review_required',
|
'withdraw.review_required',
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_disconnected')" show-icon class="mb-12" />
|
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_disconnected')" show-icon class="mb-12" />
|
||||||
|
|
||||||
<el-card shadow="never" class="mb-12">
|
<el-card shadow="never" class="mb-12">
|
||||||
|
<el-alert v-if="snapshot.is_payout_phase" type="warning" :title="t('game.live.payout_phase')" show-icon class="mb-12" />
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div>
|
<div>
|
||||||
<div>{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}</div>
|
<div>{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}</div>
|
||||||
<div>{{ t('game.live.ai_default_number') }}: {{ snapshot.ai_default_number ?? '-' }}</div>
|
<div>{{ t('game.live.ai_default_number') }}: {{ snapshot.ai_default_number ?? '-' }}</div>
|
||||||
|
<div v-if="snapshot.pending_draw_number != null">
|
||||||
|
{{ t('game.live.pending_draw') }}: {{ snapshot.pending_draw_number }}
|
||||||
|
</div>
|
||||||
<div>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
|
<div>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -15,7 +19,7 @@
|
|||||||
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
|
||||||
{{ t('game.live.btn_calc') }}
|
{{ t('game.live.btn_calc') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_draw" @click="onDraw">
|
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_schedule_draw" @click="onDraw">
|
||||||
{{ t('game.live.btn_draw') }}
|
{{ t('game.live.btn_draw') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||||||
@@ -67,6 +71,7 @@ interface Snapshot {
|
|||||||
bets: anyObj[]
|
bets: anyObj[]
|
||||||
candidate_numbers: anyObj[]
|
candidate_numbers: anyObj[]
|
||||||
ai_default_number: number | null
|
ai_default_number: number | null
|
||||||
|
pending_draw_number: number | null
|
||||||
period_seconds?: number
|
period_seconds?: number
|
||||||
bet_seconds?: number
|
bet_seconds?: number
|
||||||
pick_max_number_count?: number
|
pick_max_number_count?: number
|
||||||
@@ -74,8 +79,12 @@ interface Snapshot {
|
|||||||
draw_number_max?: number
|
draw_number_max?: number
|
||||||
remaining_seconds?: number
|
remaining_seconds?: number
|
||||||
bet_remaining_seconds?: number
|
bet_remaining_seconds?: number
|
||||||
|
payout_remaining_seconds?: number
|
||||||
|
is_payout_phase?: boolean
|
||||||
can_calculate?: boolean
|
can_calculate?: boolean
|
||||||
can_draw?: boolean
|
can_draw?: boolean
|
||||||
|
can_schedule_draw?: boolean
|
||||||
|
server_time?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -87,14 +96,18 @@ const snapshot = reactive<Snapshot>({
|
|||||||
bets: [],
|
bets: [],
|
||||||
candidate_numbers: [],
|
candidate_numbers: [],
|
||||||
ai_default_number: null,
|
ai_default_number: null,
|
||||||
|
pending_draw_number: null,
|
||||||
period_seconds: 30,
|
period_seconds: 30,
|
||||||
bet_seconds: 20,
|
bet_seconds: 20,
|
||||||
pick_max_number_count: 10,
|
pick_max_number_count: 10,
|
||||||
draw_number_max: 36,
|
draw_number_max: 36,
|
||||||
remaining_seconds: 0,
|
remaining_seconds: 0,
|
||||||
bet_remaining_seconds: 0,
|
bet_remaining_seconds: 0,
|
||||||
|
payout_remaining_seconds: 0,
|
||||||
|
is_payout_phase: false,
|
||||||
can_calculate: false,
|
can_calculate: false,
|
||||||
can_draw: false,
|
can_draw: false,
|
||||||
|
can_schedule_draw: false,
|
||||||
})
|
})
|
||||||
const calcLoading = ref(false)
|
const calcLoading = ref(false)
|
||||||
const drawLoading = ref(false)
|
const drawLoading = ref(false)
|
||||||
@@ -102,6 +115,12 @@ const manualNumber = ref<number | null>(1)
|
|||||||
const calcResultNumber = ref<number | null>(null)
|
const calcResultNumber = ref<number | null>(null)
|
||||||
const calcEstimatedLoss = ref<string>('0.0000')
|
const calcEstimatedLoss = ref<string>('0.0000')
|
||||||
|
|
||||||
|
/** 服务端 Unix 秒 − 本地 Unix 秒,用于派彩倒计时与服务器对齐 */
|
||||||
|
const serverSkewSeconds = ref(0)
|
||||||
|
/** 每秒递增,驱动派彩剩余秒本地刷新 */
|
||||||
|
const clockTick = ref(0)
|
||||||
|
let clockTimer: number | null = null
|
||||||
|
|
||||||
let pushClient: any = null
|
let pushClient: any = null
|
||||||
let pushChannel: any = null
|
let pushChannel: any = null
|
||||||
let pollTimer: number | null = null
|
let pollTimer: number | null = null
|
||||||
@@ -113,6 +132,45 @@ function formatPicks(v: unknown): string {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncServerClock(serverTime: unknown): void {
|
||||||
|
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
|
||||||
|
serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000)
|
||||||
|
snapshot.server_time = serverTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPayoutUntilUnix(rec: anyObj | null): number | null {
|
||||||
|
if (!rec) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const v = rec.payout_until
|
||||||
|
if (v === null || v === undefined || v === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if (typeof v === 'string' && /^\d+$/.test(v)) {
|
||||||
|
return parseInt(v, 10)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 派彩剩余秒:优先用 payout_until 与对时后的「服务器当前秒」计算,便于每秒递减 */
|
||||||
|
const payoutRemainingLive = computed(() => {
|
||||||
|
clockTick.value
|
||||||
|
if (!snapshot.is_payout_phase) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const until = readPayoutUntilUnix(snapshot.record)
|
||||||
|
if (until !== null) {
|
||||||
|
const serverNow = Math.floor(Date.now() / 1000) + serverSkewSeconds.value
|
||||||
|
const diff = until - serverNow
|
||||||
|
return diff > 0 ? diff : 0
|
||||||
|
}
|
||||||
|
return snapshot.payout_remaining_seconds ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
async function loadSnapshot() {
|
async function loadSnapshot() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -122,14 +180,20 @@ async function loadSnapshot() {
|
|||||||
snapshot.bets = res.data.bets || []
|
snapshot.bets = res.data.bets || []
|
||||||
snapshot.candidate_numbers = res.data.candidate_numbers || []
|
snapshot.candidate_numbers = res.data.candidate_numbers || []
|
||||||
snapshot.ai_default_number = res.data.ai_default_number
|
snapshot.ai_default_number = res.data.ai_default_number
|
||||||
|
snapshot.pending_draw_number =
|
||||||
|
typeof res.data.pending_draw_number === 'number' ? res.data.pending_draw_number : null
|
||||||
snapshot.period_seconds = res.data.period_seconds ?? 30
|
snapshot.period_seconds = res.data.period_seconds ?? 30
|
||||||
snapshot.bet_seconds = res.data.bet_seconds ?? 20
|
snapshot.bet_seconds = res.data.bet_seconds ?? 20
|
||||||
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
|
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
|
||||||
snapshot.draw_number_max = res.data.draw_number_max ?? 36
|
snapshot.draw_number_max = res.data.draw_number_max ?? 36
|
||||||
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
|
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
|
||||||
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
|
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
|
||||||
|
snapshot.payout_remaining_seconds = res.data.payout_remaining_seconds ?? 0
|
||||||
|
snapshot.is_payout_phase = !!res.data.is_payout_phase
|
||||||
snapshot.can_calculate = !!res.data.can_calculate
|
snapshot.can_calculate = !!res.data.can_calculate
|
||||||
snapshot.can_draw = !!res.data.can_draw
|
snapshot.can_draw = !!res.data.can_draw
|
||||||
|
snapshot.can_schedule_draw = !!res.data.can_schedule_draw || !!res.data.can_draw
|
||||||
|
syncServerClock(res.data.server_time)
|
||||||
const dmax = res.data.draw_number_max ?? 36
|
const dmax = res.data.draw_number_max ?? 36
|
||||||
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1
|
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1
|
||||||
}
|
}
|
||||||
@@ -172,14 +236,20 @@ async function initPush() {
|
|||||||
snapshot.bets = payload.bets || []
|
snapshot.bets = payload.bets || []
|
||||||
snapshot.candidate_numbers = payload.candidate_numbers || []
|
snapshot.candidate_numbers = payload.candidate_numbers || []
|
||||||
snapshot.ai_default_number = payload.ai_default_number ?? null
|
snapshot.ai_default_number = payload.ai_default_number ?? null
|
||||||
|
snapshot.pending_draw_number =
|
||||||
|
typeof payload.pending_draw_number === 'number' ? payload.pending_draw_number : null
|
||||||
snapshot.period_seconds = payload.period_seconds ?? 30
|
snapshot.period_seconds = payload.period_seconds ?? 30
|
||||||
snapshot.bet_seconds = payload.bet_seconds ?? 20
|
snapshot.bet_seconds = payload.bet_seconds ?? 20
|
||||||
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
|
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
|
||||||
snapshot.draw_number_max = payload.draw_number_max ?? 36
|
snapshot.draw_number_max = payload.draw_number_max ?? 36
|
||||||
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
|
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
|
||||||
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
|
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
|
||||||
|
snapshot.payout_remaining_seconds = payload.payout_remaining_seconds ?? 0
|
||||||
|
snapshot.is_payout_phase = !!payload.is_payout_phase
|
||||||
snapshot.can_calculate = !!payload.can_calculate
|
snapshot.can_calculate = !!payload.can_calculate
|
||||||
snapshot.can_draw = !!payload.can_draw
|
snapshot.can_draw = !!payload.can_draw
|
||||||
|
snapshot.can_schedule_draw = !!payload.can_schedule_draw || !!payload.can_draw
|
||||||
|
syncServerClock(payload.server_time)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
pushConnected.value = false
|
pushConnected.value = false
|
||||||
@@ -244,12 +314,19 @@ async function onDraw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countdownText = computed(() => {
|
const countdownText = computed(() => {
|
||||||
const total = snapshot.remaining_seconds ?? 0
|
|
||||||
const bet = snapshot.bet_remaining_seconds ?? 0
|
const bet = snapshot.bet_remaining_seconds ?? 0
|
||||||
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${total}s`
|
const draw = snapshot.remaining_seconds ?? 0
|
||||||
|
let payoutPart = t('game.live.payout_na')
|
||||||
|
if (snapshot.is_payout_phase && payoutRemainingLive.value !== null) {
|
||||||
|
payoutPart = `${payoutRemainingLive.value}s`
|
||||||
|
}
|
||||||
|
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${draw}s / ${t('game.live.payout_countdown')} ${payoutPart}`
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
clockTimer = window.setInterval(() => {
|
||||||
|
clockTick.value++
|
||||||
|
}, 1000)
|
||||||
await loadSnapshot()
|
await loadSnapshot()
|
||||||
try {
|
try {
|
||||||
await initPush()
|
await initPush()
|
||||||
@@ -269,6 +346,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
stopPolling()
|
stopPolling()
|
||||||
stopPushWatchdog()
|
stopPushWatchdog()
|
||||||
|
if (clockTimer !== null) {
|
||||||
|
window.clearInterval(clockTimer)
|
||||||
|
clockTimer = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const logText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function appendLog(line: PushTestLogLine) {
|
function appendLog(line: PushTestLogLine) {
|
||||||
logs.value = [...logs.value, line].slice(-200)
|
logs.value = [line, ...logs.value].slice(0, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLogs() {
|
function clearLogs() {
|
||||||
|
|||||||
Reference in New Issue
Block a user