1.重构实时消息WebSocket连接

2.MySQL备份
This commit is contained in:
2026-04-24 13:49:38 +08:00
parent d69412a0f7
commit fd324f2882
54 changed files with 2396 additions and 2638 deletions

View File

@@ -134,33 +134,6 @@ final class GameBetSettleService
GameHotDataCoordinator::afterUserCommitted($userId);
}
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', 2) > 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'],
]);
}
}
$jackpotHits = [];
foreach ($jackpotNotify as $uid => $_) {
if (!isset($aggregateByUser[$uid])) {
@@ -210,9 +183,8 @@ final class GameBetSettleService
}
Db::startTrans();
try {
$out = self::settleBetsForDraw($rid, $rn);
self::settleBetsForDraw($rid, $rn);
Db::commit();
JackpotPushService::publishHits($out['jackpot_hits'] ?? []);
$count++;
} catch (Throwable $e) {
Db::rollback();
@@ -322,6 +294,13 @@ final class GameBetSettleService
'update_time' => $now,
]);
GameHotDataCoordinator::afterUserCommitted($userId);
GameWebSocketEventBus::publish('wallet.changed', [
'user_id' => $userId,
'balance_after' => $after,
'biz_type' => 'payout',
'ref_id' => $betId,
'changed_at' => $now,
]);
return $after;
}

View File

@@ -10,7 +10,6 @@ use app\common\service\GameHotDataCoordinator;
use app\common\service\GameHotDataLock;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
final class GameLiveService
{
@@ -384,8 +383,6 @@ final class GameLiveService
GameRecordStatService::refreshForRecordId($rid);
} catch (Throwable) {
}
JackpotPushService::publishHits($settleOut['jackpot_hits'] ?? []);
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
self::publishSnapshot(null);
@@ -611,72 +608,60 @@ final class GameLiveService
public static function publishSnapshot(?int $recordId = null): void
{
try {
$payload = self::buildSnapshot($recordId);
$api = self::createPushApi();
$api->trigger(self::CHANNEL, self::EVENT, $payload);
self::publishPublicPeriodTick($payload, $api);
} catch (Throwable) {
}
}
private static function createPushApi(): 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')
);
$snapshot = self::buildSnapshot($recordId);
self::publishPublicPeriodTick($snapshot);
}
/**
* 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义)
*/
private static function publishPublicPeriodTick(array $snapshot, Api $api): void
private static function publishPublicPeriodTick(array $snapshot): void
{
$record = $snapshot['record'] ?? null;
$serverTime = (int) ($snapshot['server_time'] ?? time());
$remaining = (int) ($snapshot['remaining_seconds'] ?? 0);
$betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0);
$payoutRem = (int) ($snapshot['payout_remaining_seconds'] ?? 0);
$isPayout = !empty($snapshot['is_payout_phase']);
$periodNo = '';
$dbStatus = 0;
$periodId = 0;
$status = 'finished';
$resultNumber = null;
if (is_array($record)) {
$periodNo = (string) ($record['period_no'] ?? '');
$dbStatus = (int) ($record['status'] ?? 0);
$rn = $record['result_number'] ?? null;
$resultNumber = is_numeric((string) $rn) ? (int) $rn : null;
}
if ($record === null || $periodNo === '') {
$status = 'idle';
} else {
$status = self::mapPublicPeriodStatus($dbStatus, $betCloseIn);
}
$payload = [
'server_time' => $serverTime,
'period_no' => $periodNo,
'status' => $status,
'countdown' => $remaining,
'bet_close_in'=> $betCloseIn,
'payout_remaining_seconds' => $payoutRem,
'is_payout_phase' => $isPayout,
'payout_message' => $isPayout ? '派彩中,请稍候' : '',
];
if ($periodNo !== '' && $record !== null) {
$start = (int) ($record['period_start_at'] ?? 0);
$betSeconds = (int) ($snapshot['bet_seconds'] ?? 20);
$periodSeconds = (int) ($snapshot['period_seconds'] ?? 30);
if ($start > 0) {
$payload['lock_at'] = $start + $betSeconds;
$payload['open_at'] = $start + $periodSeconds;
$periodNoRaw = $record['period_no'] ?? '';
if (is_string($periodNoRaw)) {
$periodNo = $periodNoRaw;
}
$periodIdRaw = $record['id'] ?? 0;
$periodIdParsed = filter_var($periodIdRaw, FILTER_VALIDATE_INT);
if ($periodIdParsed !== false && $periodIdParsed > 0) {
$periodId = $periodIdParsed;
}
$dbStatusRaw = $record['status'] ?? 4;
$dbStatus = filter_var($dbStatusRaw, FILTER_VALIDATE_INT);
if ($dbStatus === false) {
$dbStatus = 4;
}
$betRemainingRaw = $snapshot['bet_remaining_seconds'] ?? 0;
$betRemaining = filter_var($betRemainingRaw, FILTER_VALIDATE_INT);
if ($betRemaining === false || $betRemaining < 0) {
$betRemaining = 0;
}
$status = self::mapPublicPeriodStatus($dbStatus, $betRemaining);
$resultRaw = $record['result_number'] ?? null;
$resultParsed = filter_var($resultRaw, FILTER_VALIDATE_INT);
if ($resultParsed !== false && $resultParsed > 0) {
$resultNumber = $resultParsed;
}
}
if ($resultNumber !== null) {
$payload['result_number'] = $resultNumber;
}
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_TICK, $payload);
$payload = [
'period_id' => $periodId,
'period_no' => $periodNo,
'status' => $status,
'countdown' => max(0, self::safeInt($snapshot['remaining_seconds'] ?? 0)),
'bet_close_in' => max(0, self::safeInt($snapshot['bet_remaining_seconds'] ?? 0)),
'result_number' => $resultNumber,
'runtime_enabled' => !empty($snapshot['runtime_enabled']),
'server_time' => time(),
];
GameWebSocketEventBus::publish(self::EVT_PERIOD_TICK, $payload);
}
/**
@@ -684,32 +669,26 @@ final class GameLiveService
*/
private static function publishPublicPeriodLocked(array $record): void
{
try {
$start = (int) ($record['period_start_at'] ?? 0);
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
$periodNo = (string) ($record['period_no'] ?? '');
$payload = [
'period_no' => $periodNo,
'lock_at' => $start > 0 ? $start + $betSeconds : time(),
];
$api = self::createPushApi();
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_LOCKED, $payload);
} catch (Throwable) {
$periodNo = is_string($record['period_no'] ?? null) ? $record['period_no'] : '';
$periodId = filter_var($record['id'] ?? 0, FILTER_VALIDATE_INT);
if ($periodId === false) {
$periodId = 0;
}
GameWebSocketEventBus::publish(self::EVT_PERIOD_LOCKED, [
'period_id' => $periodId,
'period_no' => $periodNo,
'status' => 'locked',
'server_time' => time(),
]);
}
private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void
{
try {
$payload = [
'period_no' => $periodNo,
'result_number' => $resultNumber,
'open_time' => $openTime,
];
$api = self::createPushApi();
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_OPENED, $payload);
} catch (Throwable) {
}
GameWebSocketEventBus::publish(self::EVT_PERIOD_OPENED, [
'period_no' => $periodNo,
'result_number' => $resultNumber,
'open_time' => $openTime,
]);
}
/**
@@ -717,17 +696,12 @@ 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) {
}
GameWebSocketEventBus::publish(self::EVT_PERIOD_PAYOUT, [
'period_no' => $periodNo,
'result_number' => $resultNumber,
'payout_until' => $payoutUntil,
'server_time' => time(),
]);
}
/**
@@ -911,4 +885,13 @@ final class GameLiveService
$index = random_int(0, count($numbers) - 1);
return $numbers[$index];
}
private static function safeInt($value): int
{
$parsed = filter_var($value, FILTER_VALIDATE_INT);
if ($parsed === false) {
return 0;
}
return $parsed;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\Redis;
use Throwable;
/**
* 通过 Redis 列表在不同进程间投递 WebSocket 事件。
*/
final class GameWebSocketEventBus
{
private const KEY_QUEUE = 'dfw:v1:ws:event:queue';
private const MAX_BATCH = 100;
/**
* @param array<string, mixed> $data
*/
public static function publish(string $topic, array $data): void
{
$topic = trim($topic);
if ($topic === '') {
return;
}
$payload = [
'topic' => $topic,
'event' => $topic,
'data' => $data,
'server_time' => time(),
];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json) || $json === '') {
return;
}
try {
Redis::lPush(self::KEY_QUEUE, $json);
} catch (Throwable) {
}
}
/**
* @return list<array{topic:string,event:string,data:array<string,mixed>,server_time:int}>
*/
public static function popBatch(int $limit = self::MAX_BATCH): array
{
if ($limit <= 0) {
return [];
}
if ($limit > self::MAX_BATCH) {
$limit = self::MAX_BATCH;
}
$out = [];
try {
for ($i = 0; $i < $limit; $i++) {
$raw = Redis::rPop(self::KEY_QUEUE);
if (!is_string($raw) || $raw === '') {
break;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
continue;
}
$topicRaw = $decoded['topic'] ?? '';
$eventRaw = $decoded['event'] ?? '';
$dataRaw = $decoded['data'] ?? [];
$serverTimeRaw = $decoded['server_time'] ?? time();
if (!is_string($topicRaw) || trim($topicRaw) === '') {
continue;
}
$topic = trim($topicRaw);
$event = is_string($eventRaw) && trim($eventRaw) !== '' ? trim($eventRaw) : $topic;
$data = is_array($dataRaw) ? $dataRaw : [];
$serverTime = filter_var($serverTimeRaw, FILTER_VALIDATE_INT);
if ($serverTime === false || $serverTime <= 0) {
$serverTime = time();
}
$out[] = [
'topic' => $topic,
'event' => $event,
'data' => $data,
'server_time' => $serverTime,
];
}
} catch (Throwable) {
return $out;
}
return $out;
}
}

View File

@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use Throwable;
use Webman\Push\Api;
/**
* 大奖派彩:玩家私有频道 + 公共频道(对局频道 + 公告频道,便于大厅与公告测试页均能收到)
*/
final class JackpotPushService
{
private const CHANNEL_GAME_PERIOD = 'public-game-period';
private const CHANNEL_OPERATION_NOTICE = 'public-operation-notice';
private const EVT_JACKPOT_HIT = 'jackpot.hit';
/**
* @param list<array{user_id: int, period_no: string, total_win: string, result_number: int}> $hits
*/
public static function publishHits(array $hits): void
{
foreach ($hits as $h) {
$uid = (int) ($h['user_id'] ?? 0);
if ($uid <= 0) {
continue;
}
$periodNo = (string) ($h['period_no'] ?? '');
$totalWin = (string) ($h['total_win'] ?? '0');
$rn = (int) ($h['result_number'] ?? 0);
UserPushService::publish($uid, UserPushService::EVT_JACKPOT_HIT, [
'period_no' => $periodNo,
'total_win_amount' => $totalWin,
'result_number' => $rn,
'is_jackpot' => true,
]);
self::publishPublicChannels($periodNo, $uid, $totalWin, $rn);
}
}
/**
* @param array<string, mixed> $payload
*/
private static function triggerChannel(Api $api, string $channel, array $payload): void
{
$api->trigger($channel, self::EVT_JACKPOT_HIT, $payload);
}
private static function publishPublicChannels(string $periodNo, int $userId, string $totalWin, int $resultNumber): void
{
try {
$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')
);
$payload = [
'period_no' => $periodNo,
'user_id' => $userId,
'total_win_amount' => $totalWin,
'result_number' => $resultNumber,
'message' => '恭喜玩家命中大奖派彩',
];
self::triggerChannel($api, self::CHANNEL_GAME_PERIOD, $payload);
self::triggerChannel($api, self::CHANNEL_OPERATION_NOTICE, $payload);
} catch (Throwable) {
}
}
}

View File

@@ -1,64 +0,0 @@
<?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';
/** 命中配置为「大奖」的连胜档派彩(私有频道) */
public const EVT_JACKPOT_HIT = 'jackpot.hit';
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) {
}
}
}