1.优化websocket接口,新增赔率参数
This commit is contained in:
@@ -6,6 +6,7 @@ namespace app\admin\controller\test;
|
|||||||
|
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
use app\common\library\admin\WebSocketConfigHelper;
|
use app\common\library\admin\WebSocketConfigHelper;
|
||||||
|
use app\common\service\GameWebSocketPayloadHelper;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
use Webman\Http\Request as WebmanRequest;
|
use Webman\Http\Request as WebmanRequest;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class GameCurrentStatus extends Backend
|
|||||||
|
|
||||||
$subscribeTopics = [
|
$subscribeTopics = [
|
||||||
'period.tick',
|
'period.tick',
|
||||||
|
'user.streak',
|
||||||
'period.opened',
|
'period.opened',
|
||||||
'period.locked',
|
'period.locked',
|
||||||
'period.payout',
|
'period.payout',
|
||||||
@@ -33,15 +35,22 @@ class GameCurrentStatus extends Backend
|
|||||||
'auto.spin.progress',
|
'auto.spin.progress',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$oddsPushTopics = GameWebSocketPayloadHelper::ODDS_PUSH_TOPICS;
|
||||||
|
$testPlayerOdds = GameWebSocketPayloadHelper::adminTestPlayerOddsSnapshot();
|
||||||
|
|
||||||
return $this->success('', [
|
return $this->success('', [
|
||||||
'name' => 'ws.period',
|
'name' => 'ws.period',
|
||||||
'ws_url' => WebSocketConfigHelper::wsUrl($request),
|
'ws_url' => WebSocketConfigHelper::wsUrl($request),
|
||||||
'connect_tip' => 'After connected, topics are auto-subscribed. You can also send subscribe manually.',
|
'connect_tip' => '连接成功后将自动订阅下列主题。真实业务仅在有玩家下注/结算时推送赔率;本页联调会在订阅后额外推送带 is_test/preview 的演示帧(见下方测试玩家赔率)。',
|
||||||
'subscribe_topics' => $subscribeTopics,
|
'subscribe_topics' => $subscribeTopics,
|
||||||
|
'odds_push_topics' => $oddsPushTopics,
|
||||||
|
'player_odds_fields' => ['current_streak', 'streak_level', 'odds_factor', 'is_jackpot'],
|
||||||
|
'test_player_odds' => $testPlayerOdds,
|
||||||
|
'test_push_topics' => $oddsPushTopics,
|
||||||
'sample_messages' => [
|
'sample_messages' => [
|
||||||
'{"action":"ping"}',
|
'{"action":"ping"}',
|
||||||
'{"action":"subscribe","topics":["period.tick","period.opened"]}',
|
'{"action":"subscribe","topics":["period.tick","user.streak","period.opened","period.locked","period.payout"]}',
|
||||||
'{"action":"subscribe","topics":["bet.accepted","wallet.changed","auto.spin.progress"]}',
|
'{"action":"subscribe","topics":["user.streak","wallet.changed","bet.accepted","auto.spin.progress"]}',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use app\common\service\GameHotDataCoordinator;
|
|||||||
use app\common\service\GameHotDataRedis;
|
use app\common\service\GameHotDataRedis;
|
||||||
use app\common\service\GameRecordService;
|
use app\common\service\GameRecordService;
|
||||||
use app\common\service\GameWebSocketEventBus;
|
use app\common\service\GameWebSocketEventBus;
|
||||||
|
use app\common\service\GameWebSocketPayloadHelper;
|
||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use Webman\Http\Request;
|
use Webman\Http\Request;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
@@ -49,6 +50,9 @@ class Game extends MobileBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->auth->getUser();
|
$user = $this->auth->getUser();
|
||||||
|
$currentStreakRaw = $user->current_streak ?? 0;
|
||||||
|
$currentStreakParsed = filter_var($currentStreakRaw, FILTER_VALIDATE_INT);
|
||||||
|
$currentStreak = $currentStreakParsed === false ? 0 : $currentStreakParsed;
|
||||||
return $this->mobileSuccess([
|
return $this->mobileSuccess([
|
||||||
'server_time' => $now,
|
'server_time' => $now,
|
||||||
'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE),
|
'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE),
|
||||||
@@ -68,11 +72,12 @@ class Game extends MobileBase
|
|||||||
BetChips::lobbyChipsPayload()
|
BetChips::lobbyChipsPayload()
|
||||||
),
|
),
|
||||||
'dictionary' => $items,
|
'dictionary' => $items,
|
||||||
'streak_win_reward' => StreakWinReward::lobbyPayload(),
|
'user_snapshot' => array_merge(
|
||||||
'user_snapshot' => [
|
[
|
||||||
'coin' => $user->coin,
|
'coin' => $user->coin,
|
||||||
'current_streak' => $user->current_streak ?? 0,
|
],
|
||||||
],
|
StreakWinReward::playerBetOddsForCurrentStreak($currentStreak)
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +285,7 @@ class Game extends MobileBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||||
GameWebSocketEventBus::publish('bet.accepted', [
|
GameWebSocketEventBus::publish('bet.accepted', GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'period_no' => $period->period_no,
|
'period_no' => $period->period_no,
|
||||||
'numbers' => $numbers,
|
'numbers' => $numbers,
|
||||||
@@ -290,13 +295,13 @@ class Game extends MobileBase
|
|||||||
'total_amount' => $totalAmount,
|
'total_amount' => $totalAmount,
|
||||||
'balance_after' => $after,
|
'balance_after' => $after,
|
||||||
'accepted_at' => time(),
|
'accepted_at' => time(),
|
||||||
]);
|
], $userId, $streakAtBet));
|
||||||
GameWebSocketEventBus::publish('wallet.changed', [
|
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'balance_after' => $after,
|
'balance_after' => $after,
|
||||||
'biz_type' => 'bet',
|
'biz_type' => 'bet',
|
||||||
'changed_at' => time(),
|
'changed_at' => time(),
|
||||||
]);
|
], $userId, $streakAtBet));
|
||||||
return $this->mobileSuccess([
|
return $this->mobileSuccess([
|
||||||
'order_no' => $orderNo,
|
'order_no' => $orderNo,
|
||||||
'period_no' => $period->period_no,
|
'period_no' => $period->period_no,
|
||||||
|
|||||||
@@ -184,13 +184,13 @@ final class DepositSettlement
|
|||||||
throw new RuntimeException($e->getMessage());
|
throw new RuntimeException($e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
GameWebSocketEventBus::publish('wallet.changed', [
|
GameWebSocketEventBus::publish('wallet.changed', \app\common\service\GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'balance_after' => $balanceAfter,
|
'balance_after' => $balanceAfter,
|
||||||
'biz_type' => 'deposit',
|
'biz_type' => 'deposit',
|
||||||
'order_no' => $orderNo,
|
'order_no' => $orderNo,
|
||||||
'changed_at' => $now,
|
'changed_at' => $now,
|
||||||
]);
|
], $userId));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'order_id' => $orderId,
|
'order_id' => $orderId,
|
||||||
|
|||||||
@@ -107,22 +107,23 @@ final class StreakWinReward
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* lobbyInit 用:连胜赔率档位(与后台「连胜奖励」、派彩公式一致)。
|
* 当前玩家本局适用赔率(非全表):按 current_streak 解析下一注中奖将使用的档位。
|
||||||
*
|
*
|
||||||
* @return array{rows: list<array{streak: int, odds_factor: int, is_jackpot: bool}>}
|
* @return array{current_streak: int, streak_level: int, odds_factor: int, is_jackpot: bool}
|
||||||
*/
|
*/
|
||||||
public static function lobbyPayload(): array
|
public static function playerBetOddsForCurrentStreak(int $currentStreak): array
|
||||||
{
|
{
|
||||||
$rows = [];
|
if ($currentStreak < 0) {
|
||||||
foreach (self::loadRows() as $row) {
|
$currentStreak = 0;
|
||||||
$rows[] = [
|
|
||||||
'streak' => (int) ($row['streak'] ?? 0),
|
|
||||||
'odds_factor' => (int) ($row['odds_factor'] ?? 0),
|
|
||||||
'is_jackpot' => ($row['is_jackpot'] ?? false) === true,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
$row = self::rowForStreakAtBet($currentStreak);
|
||||||
|
|
||||||
return ['rows' => $rows];
|
return [
|
||||||
|
'current_streak' => $currentStreak,
|
||||||
|
'streak_level' => self::levelFromStreakAtBet($currentStreak),
|
||||||
|
'odds_factor' => (int) ($row['odds_factor'] ?? 1),
|
||||||
|
'is_jackpot' => ($row['is_jackpot'] ?? false) === true,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ final class GameBetSettleService
|
|||||||
'update_time' => $now,
|
'update_time' => $now,
|
||||||
]);
|
]);
|
||||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||||
|
GameWebSocketPayloadHelper::publishUserStreak($userId, $next);
|
||||||
}
|
}
|
||||||
|
|
||||||
$jackpotHits = [];
|
$jackpotHits = [];
|
||||||
@@ -408,13 +409,13 @@ final class GameBetSettleService
|
|||||||
'update_time' => $now,
|
'update_time' => $now,
|
||||||
]);
|
]);
|
||||||
GameHotDataCoordinator::afterUserCommitted($userId);
|
GameHotDataCoordinator::afterUserCommitted($userId);
|
||||||
GameWebSocketEventBus::publish('wallet.changed', [
|
GameWebSocketEventBus::publish('wallet.changed', GameWebSocketPayloadHelper::mergeUserStreakInto([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'balance_after' => $after,
|
'balance_after' => $after,
|
||||||
'biz_type' => 'payout',
|
'biz_type' => 'payout',
|
||||||
'ref_id' => $betId,
|
'ref_id' => $betId,
|
||||||
'changed_at' => $now,
|
'changed_at' => $now,
|
||||||
]);
|
], $userId));
|
||||||
|
|
||||||
return $after;
|
return $after;
|
||||||
}
|
}
|
||||||
|
|||||||
232
app/common/service/GameWebSocketPayloadHelper.php
Normal file
232
app/common/service/GameWebSocketPayloadHelper.php
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\game\StreakWinReward;
|
||||||
|
use support\think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移动端 WebSocket:仅推送当前玩家本局适用赔率(非 streak_win_reward 全表)。
|
||||||
|
*/
|
||||||
|
final class GameWebSocketPayloadHelper
|
||||||
|
{
|
||||||
|
public const TOPIC_USER_STREAK = 'user.streak';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ODDS_PUSH_TOPICS = [
|
||||||
|
'user.streak',
|
||||||
|
'wallet.changed',
|
||||||
|
'bet.accepted',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{user_id: int, current_streak: int, streak_level: int, odds_factor: int, is_jackpot: bool}
|
||||||
|
*/
|
||||||
|
public static function userStreakData(int $userId, ?int $currentStreak = null): array
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return [
|
||||||
|
'user_id' => 0,
|
||||||
|
'current_streak' => 0,
|
||||||
|
'streak_level' => 1,
|
||||||
|
'odds_factor' => 1,
|
||||||
|
'is_jackpot' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($currentStreak === null) {
|
||||||
|
$row = GameHotDataRedis::userRow($userId);
|
||||||
|
$raw = $row['current_streak'] ?? 0;
|
||||||
|
$parsed = filter_var($raw, FILTER_VALIDATE_INT);
|
||||||
|
$currentStreak = $parsed === false ? 0 : $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$odds = StreakWinReward::playerBetOddsForCurrentStreak($currentStreak);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'current_streak' => $odds['current_streak'],
|
||||||
|
'streak_level' => $odds['streak_level'],
|
||||||
|
'odds_factor' => $odds['odds_factor'],
|
||||||
|
'is_jackpot' => $odds['is_jackpot'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function mergeUserStreakInto(array $payload, int $userId, ?int $currentStreak = null): array
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($payload, self::userStreakData($userId, $currentStreak));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function publishUserStreak(int $userId, ?int $currentStreak = null): void
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GameWebSocketEventBus::publish(self::TOPIC_USER_STREAK, self::userStreakData($userId, $currentStreak));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台 WebSocket 联调:从库内选取样例玩家(优先 current_streak 最高)。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function pickAdminTestUserRow(): ?array
|
||||||
|
{
|
||||||
|
$fields = ['id', 'username', 'uuid', 'phone', 'current_streak', 'coin'];
|
||||||
|
$row = Db::name('user')
|
||||||
|
->where('status', 1)
|
||||||
|
->order('current_streak', 'desc')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->field($fields)
|
||||||
|
->find();
|
||||||
|
if (is_array($row) && !empty($row['id'])) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
$fallback = Db::name('user')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->field($fields)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
return is_array($fallback) && !empty($fallback['id']) ? $fallback : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wsConfig 与订阅后演示推送共用的玩家赔率快照。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function adminTestPlayerOddsSnapshot(): array
|
||||||
|
{
|
||||||
|
$row = self::pickAdminTestUserRow();
|
||||||
|
if ($row === null) {
|
||||||
|
$demoStreak = 3;
|
||||||
|
$odds = StreakWinReward::playerBetOddsForCurrentStreak($demoStreak);
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
'is_test' => true,
|
||||||
|
'preview' => true,
|
||||||
|
'user_id' => 0,
|
||||||
|
'username' => '演示玩家(库内无用户)',
|
||||||
|
'uuid' => '',
|
||||||
|
'phone' => '',
|
||||||
|
'coin' => '0.00',
|
||||||
|
'source' => 'synthetic',
|
||||||
|
], $odds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userIdRaw = $row['id'] ?? 0;
|
||||||
|
$userIdParsed = filter_var($userIdRaw, FILTER_VALIDATE_INT);
|
||||||
|
$userId = $userIdParsed === false ? 0 : $userIdParsed;
|
||||||
|
$streakRaw = $row['current_streak'] ?? 0;
|
||||||
|
$streakParsed = filter_var($streakRaw, FILTER_VALIDATE_INT);
|
||||||
|
$currentStreak = $streakParsed === false ? 0 : $streakParsed;
|
||||||
|
$odds = StreakWinReward::playerBetOddsForCurrentStreak($currentStreak);
|
||||||
|
|
||||||
|
return array_merge([
|
||||||
|
'is_test' => true,
|
||||||
|
'preview' => true,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'username' => (string) ($row['username'] ?? ''),
|
||||||
|
'uuid' => (string) ($row['uuid'] ?? ''),
|
||||||
|
'phone' => (string) ($row['phone'] ?? ''),
|
||||||
|
'coin' => (string) ($row['coin'] ?? '0.00'),
|
||||||
|
'source' => 'db_user',
|
||||||
|
], $odds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台测试连接订阅赔率主题后,推送演示帧(非真实业务事件)。
|
||||||
|
*
|
||||||
|
* @param list<string> $subscribedTopics
|
||||||
|
* @return list<array{topic: string, event: string, data: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
public static function adminTestPushFrames(array $subscribedTopics): array
|
||||||
|
{
|
||||||
|
$snapshot = self::adminTestPlayerOddsSnapshot();
|
||||||
|
$userIdRaw = $snapshot['user_id'] ?? 0;
|
||||||
|
$userIdParsed = filter_var($userIdRaw, FILTER_VALIDATE_INT);
|
||||||
|
$userId = $userIdParsed === false ? 0 : $userIdParsed;
|
||||||
|
$streakRaw = $snapshot['current_streak'] ?? 0;
|
||||||
|
$streakParsed = filter_var($streakRaw, FILTER_VALIDATE_INT);
|
||||||
|
$currentStreak = $streakParsed === false ? 0 : $streakParsed;
|
||||||
|
|
||||||
|
$topicSet = [];
|
||||||
|
foreach ($subscribedTopics as $topic) {
|
||||||
|
if (!is_string($topic)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = trim($topic);
|
||||||
|
if ($value !== '') {
|
||||||
|
$topicSet[$value] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$frames = [];
|
||||||
|
if (isset($topicSet[self::TOPIC_USER_STREAK])) {
|
||||||
|
$frames[] = [
|
||||||
|
'topic' => self::TOPIC_USER_STREAK,
|
||||||
|
'event' => self::TOPIC_USER_STREAK,
|
||||||
|
'data' => $snapshot,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isset($topicSet['wallet.changed'])) {
|
||||||
|
$frames[] = [
|
||||||
|
'topic' => 'wallet.changed',
|
||||||
|
'event' => 'wallet.changed',
|
||||||
|
'data' => self::mergeOddsFieldsFromSnapshot([
|
||||||
|
'is_test' => true,
|
||||||
|
'preview' => true,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'balance_after' => (string) ($snapshot['coin'] ?? '0.00'),
|
||||||
|
'biz_type' => 'admin_test_preview',
|
||||||
|
'changed_at' => time(),
|
||||||
|
], $snapshot),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isset($topicSet['bet.accepted'])) {
|
||||||
|
$frames[] = [
|
||||||
|
'topic' => 'bet.accepted',
|
||||||
|
'event' => 'bet.accepted',
|
||||||
|
'data' => self::mergeOddsFieldsFromSnapshot([
|
||||||
|
'is_test' => true,
|
||||||
|
'preview' => true,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'period_no' => 'ADMIN-TEST-PREVIEW',
|
||||||
|
'numbers' => [1, 2, 3],
|
||||||
|
'bet_id' => 1,
|
||||||
|
'single_bet_amount' => '1.00',
|
||||||
|
'numbers_count' => 3,
|
||||||
|
'total_amount' => '3.00',
|
||||||
|
'balance_after' => (string) ($snapshot['coin'] ?? '0.00'),
|
||||||
|
'accepted_at' => time(),
|
||||||
|
], $snapshot),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed> $snapshot
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function mergeOddsFieldsFromSnapshot(array $payload, array $snapshot): array
|
||||||
|
{
|
||||||
|
return array_merge($payload, [
|
||||||
|
'current_streak' => $snapshot['current_streak'] ?? 0,
|
||||||
|
'streak_level' => $snapshot['streak_level'] ?? 1,
|
||||||
|
'odds_factor' => $snapshot['odds_factor'] ?? 1,
|
||||||
|
'is_jackpot' => ($snapshot['is_jackpot'] ?? false) === true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace app\process;
|
|||||||
|
|
||||||
use app\common\service\GameWebSocketEventBus;
|
use app\common\service\GameWebSocketEventBus;
|
||||||
use app\common\service\GameLiveService;
|
use app\common\service\GameLiveService;
|
||||||
|
use app\common\service\GameWebSocketPayloadHelper;
|
||||||
use Workerman\Connection\TcpConnection;
|
use Workerman\Connection\TcpConnection;
|
||||||
use Workerman\Timer;
|
use Workerman\Timer;
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ class GameWebSocketServer
|
|||||||
'event' => 'ws.subscribed',
|
'event' => 'ws.subscribed',
|
||||||
'topics' => $connection->topics,
|
'topics' => $connection->topics,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
self::pushAdminTestOddsPreview($connection, $connection->topics);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,4 +196,26 @@ class GameWebSocketServer
|
|||||||
$connection->topics = [];
|
$connection->topics = [];
|
||||||
unset(self::$connections[$connection->id]);
|
unset(self::$connections[$connection->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台联调:订阅赔率相关主题后立即推送演示帧(库内样例玩家,带 is_test / preview)。
|
||||||
|
*
|
||||||
|
* @param list<string> $topics
|
||||||
|
*/
|
||||||
|
private static function pushAdminTestOddsPreview(TcpConnection $connection, array $topics): void
|
||||||
|
{
|
||||||
|
$frames = GameWebSocketPayloadHelper::adminTestPushFrames($topics);
|
||||||
|
if ($frames === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$serverTime = time();
|
||||||
|
foreach ($frames as $frame) {
|
||||||
|
$connection->send(json_encode([
|
||||||
|
'event' => $frame['event'],
|
||||||
|
'topic' => $frame['topic'],
|
||||||
|
'data' => $frame['data'],
|
||||||
|
'server_time' => $serverTime,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,17 +235,17 @@
|
|||||||
- `default_bet_chip_id`:int(含义:默认选中的筹码标识,来自 `game_config.default_bet_chip_id`,非法或指向无效档位时服务端回退为首个有效档)
|
- `default_bet_chip_id`:int(含义:默认选中的筹码标识,来自 `game_config.default_bet_chip_id`,非法或指向无效档位时服务端回退为首个有效档)
|
||||||
- `min_bet_per_number`:string(含义:单号码最小下注额,须 ≤ 所选筹码面额且受后台配置约束)
|
- `min_bet_per_number`:string(含义:单号码最小下注额,须 ≤ 所选筹码面额且受后台配置约束)
|
||||||
- `max_bet_per_number`:string(含义:单号码最大下注额)
|
- `max_bet_per_number`:string(含义:单号码最大下注额)
|
||||||
- `streak_win_reward`:object(含义:连胜赔率配置,来自 `game_config.streak_win_reward`,与后台「连胜奖励」一致)
|
|
||||||
- `rows`:array<object>(固定 1~10 档,按 `streak` 升序)
|
|
||||||
- `streak`:int(连胜档位 1~10;下注时 `streak_at_bet=0` 适用档位 1,`streak_at_bet=n` 适用档位 `min(n+1, 10)`)
|
|
||||||
- `odds_factor`:int(赔率乘数;中奖派彩 = 本笔 `total_amount` × `odds_factor`)
|
|
||||||
- `is_jackpot`:bool(是否大奖档,触发 `jackpot.hit` 等流程)
|
|
||||||
- `dictionary`:array<object>
|
- `dictionary`:array<object>
|
||||||
- `number`:int(1-36,含义:字花编号)
|
- `number`:int(1-36,含义:字花编号)
|
||||||
- `name`:string(含义:字花名称)
|
- `name`:string(含义:字花名称)
|
||||||
- `category`:string(含义:字花分类)
|
- `category`:string(含义:字花分类)
|
||||||
- `icon`:string(含义:图标资源地址)
|
- `icon`:string(含义:图标资源地址)
|
||||||
- `user_snapshot`:object(`coin`、`current_streak`,含义:用户状态快照)
|
- `user_snapshot`:object(含义:用户状态快照 + **当前玩家本局适用赔率**,不下发 1~10 全表)
|
||||||
|
- `coin`:string(余额)
|
||||||
|
- `current_streak`:int(当前连胜场数)
|
||||||
|
- `streak_level`:int(若本局中奖将使用的连胜档位 1~10,由 `min(current_streak+1, 10)` 推导)
|
||||||
|
- `odds_factor`:int(赔率乘数;中奖派彩 = 本笔 `total_amount` × `odds_factor`)
|
||||||
|
- `is_jackpot`:bool(是否大奖档)
|
||||||
|
|
||||||
### 3.2 获取36字花字典(可缓存)
|
### 3.2 获取36字花字典(可缓存)
|
||||||
- **POST** `/api/game/dictionaryList`
|
- **POST** `/api/game/dictionaryList`
|
||||||
@@ -750,9 +750,11 @@
|
|||||||
- **建议消息**:
|
- **建议消息**:
|
||||||
- 心跳:`{"action":"ping"}`
|
- 心跳:`{"action":"ping"}`
|
||||||
- 服务端对心跳的当前实现回包:`{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}`(**注意**:此处 `server_time` 为**本地时间字符串**,与业务推送帧里 `server_time` 常用**秒级 int** 不一致,客户端解析时请分支处理)
|
- 服务端对心跳的当前实现回包:`{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}`(**注意**:此处 `server_time` 为**本地时间字符串**,与业务推送帧里 `server_time` 常用**秒级 int** 不一致,客户端解析时请分支处理)
|
||||||
- 订阅状态流:`{"action":"subscribe","topics":["period.tick","period.opened"]}`
|
- 订阅状态流:`{"action":"subscribe","topics":["period.tick"]}`
|
||||||
|
- 订阅连胜/赔率(仅当前玩家):`{"action":"subscribe","topics":["user.streak","wallet.changed","bet.accepted"]}`
|
||||||
- 订阅资金流:`{"action":"subscribe","topics":["bet.accepted","wallet.changed"]}`
|
- 订阅资金流:`{"action":"subscribe","topics":["bet.accepted","wallet.changed"]}`
|
||||||
- 订阅托管流:`{"action":"subscribe","topics":["auto.spin.progress","wallet.changed"]}`
|
- 订阅托管流:`{"action":"subscribe","topics":["auto.spin.progress","wallet.changed"]}`
|
||||||
|
- 移动端推荐合并订阅:`period.tick`、`user.streak`、`wallet.changed`、`bet.accepted`、`period.opened`
|
||||||
|
|
||||||
#### 7.1.1 消息协议字段定义(联调口径)
|
#### 7.1.1 消息协议字段定义(联调口径)
|
||||||
|
|
||||||
@@ -770,10 +772,22 @@
|
|||||||
- **仅建立连接不会自动下发全部业务消息**;客户端需要发送 `subscribe` 明确订阅主题。
|
- **仅建立连接不会自动下发全部业务消息**;客户端需要发送 `subscribe` 明确订阅主题。
|
||||||
- 成功订阅后服务端返回:`{"event":"ws.subscribed","topics":[...]}`。
|
- 成功订阅后服务端返回:`{"event":"ws.subscribed","topics":[...]}`。
|
||||||
- 若未订阅主题,通常只能收到握手首帧(`ws.connected`)和心跳回包(`pong`)。
|
- 若未订阅主题,通常只能收到握手首帧(`ws.connected`)和心跳回包(`pong`)。
|
||||||
|
- **不下发** `streak_win_reward` 全表(1~10 档);赔率仅通过 `user.streak` / `wallet.changed` / `bet.accepted` 及 `lobbyInit.user_snapshot` 推送**当前登录玩家**本局适用字段。
|
||||||
|
|
||||||
|
#### 7.1.2A 连胜赔率与连胜场次(WebSocket)
|
||||||
|
|
||||||
|
- **`user.streak`**(开奖结算后推送;载荷为当前玩家本局适用赔率)
|
||||||
|
- `data.user_id`:int
|
||||||
|
- `data.current_streak`:int
|
||||||
|
- `data.streak_level`:int
|
||||||
|
- `data.odds_factor`:int
|
||||||
|
- `data.is_jackpot`:bool
|
||||||
|
- **`wallet.changed` / `bet.accepted`**:在原有字段上合并同上 **`current_streak`**、**`streak_level`**、**`odds_factor`**、**`is_jackpot`**;客户端按 `user_id` 过滤,仅处理本用户
|
||||||
|
|
||||||
#### 7.1.3 推送频率与触发规则(当前实现)
|
#### 7.1.3 推送频率与触发规则(当前实现)
|
||||||
|
|
||||||
- `period.tick`:**每秒一次**(用于倒计时、状态同步)。
|
- `period.tick`:**每秒一次**(用于倒计时、状态同步;**不含**赔率全表)。
|
||||||
|
- `user.streak`:每期结算更新用户连胜后按用户推送(未中奖也会推送,`current_streak` 可能归零)。
|
||||||
- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照)。
|
- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照)。
|
||||||
- `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。
|
- `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。
|
||||||
- `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。
|
- `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: 'WebSocket connection test for status stream: listen to period.tick / period.opened events.',
|
tip: 'WebSocket test: load config then connect to auto-subscribe. Period state via period.tick; player odds via user.streak / wallet.changed / bet.accepted (no game.config full table).',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,12 @@ export default {
|
|||||||
btn_clear: 'Clear log',
|
btn_clear: 'Clear log',
|
||||||
log_title: 'WebSocket log',
|
log_title: 'WebSocket log',
|
||||||
log_empty: 'No logs yet. Connect first and then send a message.',
|
log_empty: 'No logs yet. Connect first and then send a message.',
|
||||||
|
subscribe_topics: 'Auto-subscribe topics',
|
||||||
|
odds_push_topics: 'Odds push topics',
|
||||||
|
player_odds_fields: 'Player odds fields',
|
||||||
|
test_player_odds: 'Test player odds (config preview)',
|
||||||
|
test_player_odds_hint: 'After connect and subscribe, the server pushes demo frames with is_test / preview (from highest-streak sample user in DB).',
|
||||||
|
test_source_db: 'Sample user from DB',
|
||||||
|
test_source_synthetic: 'Synthetic demo',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default {
|
export default {
|
||||||
tip: 'WebSocket 连接测试(状态流):按文档监听 period.tick / period.opened 等事件。',
|
tip: 'WebSocket 联调:加载配置后连接即自动订阅;对局状态见 period.tick,当前玩家赔率见 user.streak / wallet.changed / bet.accepted(不含 game.config 全表)。',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,12 @@ export default {
|
|||||||
btn_clear: '清空日志',
|
btn_clear: '清空日志',
|
||||||
log_title: 'WebSocket 日志',
|
log_title: 'WebSocket 日志',
|
||||||
log_empty: '暂无日志,请先连接后发送消息。',
|
log_empty: '暂无日志,请先连接后发送消息。',
|
||||||
|
subscribe_topics: '自动订阅主题',
|
||||||
|
odds_push_topics: '赔率推送主题',
|
||||||
|
player_odds_fields: '玩家赔率字段',
|
||||||
|
test_player_odds: '测试玩家赔率(配置预览)',
|
||||||
|
test_player_odds_hint: '连接并订阅赔率主题后,服务端将推送带 is_test / preview 的演示帧(数据来自库内连胜最高的样例玩家)。',
|
||||||
|
test_source_db: '库内样例玩家',
|
||||||
|
test_source_synthetic: '合成演示',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,38 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="text-muted mb-8">{{ connectTip || '-' }}</div>
|
<div class="text-muted mb-8">{{ connectTip || '-' }}</div>
|
||||||
|
<template v-if="ready && subscribeTopics.length">
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="config-label">{{ t('test.ws.subscribe_topics') }}:</span>
|
||||||
|
<el-tag v-for="topic in subscribeTopics" :key="topic" size="small" class="mr-4 mb-4">{{ topic }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="oddsPushTopics.length" class="mb-4">
|
||||||
|
<span class="config-label">{{ t('test.ws.odds_push_topics') }}:</span>
|
||||||
|
<el-tag v-for="topic in oddsPushTopics" :key="'odds-' + topic" type="warning" size="small" class="mr-4 mb-4">{{ topic }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="playerOddsFields.length" class="mb-4">
|
||||||
|
<span class="config-label">{{ t('test.ws.player_odds_fields') }}:</span>
|
||||||
|
<el-tag v-for="field in playerOddsFields" :key="field" type="success" size="small" class="mr-4 mb-4">{{ field }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-card v-if="testPlayerOdds" shadow="never" class="test-odds-card">
|
||||||
|
<template #header>
|
||||||
|
<span>{{ t('test.ws.test_player_odds') }}</span>
|
||||||
|
<el-tag type="info" size="small" class="ml-8">{{ testPlayerOddsSourceLabel }}</el-tag>
|
||||||
|
</template>
|
||||||
|
<p class="text-muted mb-8">{{ t('test.ws.test_player_odds_hint') }}</p>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="user_id">{{ testPlayerOdds.user_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="username">{{ testPlayerOdds.username || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="uuid">{{ testPlayerOdds.uuid || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="phone">{{ testPlayerOdds.phone || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="coin">{{ testPlayerOdds.coin }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="current_streak">{{ testPlayerOdds.current_streak }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="streak_level">{{ testPlayerOdds.streak_level }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="odds_factor">{{ testPlayerOdds.odds_factor }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="is_jackpot">{{ testPlayerOdds.is_jackpot ? 'true' : 'false' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
@@ -35,7 +67,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import createAxios from '/@/utils/axios'
|
import createAxios from '/@/utils/axios'
|
||||||
|
|
||||||
@@ -58,11 +90,15 @@ const connectTip = ref('')
|
|||||||
const sendMessage = ref('')
|
const sendMessage = ref('')
|
||||||
/** 连接成功后自动订阅(由 wsConfig.subscribe_topics 下发) */
|
/** 连接成功后自动订阅(由 wsConfig.subscribe_topics 下发) */
|
||||||
const subscribeTopics = ref<string[]>([])
|
const subscribeTopics = ref<string[]>([])
|
||||||
|
const oddsPushTopics = ref<string[]>([])
|
||||||
|
const playerOddsFields = ref<string[]>([])
|
||||||
|
const testPlayerOdds = ref<Record<string, unknown> | null>(null)
|
||||||
const ws = ref<WebSocket | null>(null)
|
const ws = ref<WebSocket | null>(null)
|
||||||
const logs = ref<Array<{ t: number; event: string; payload: string }>>([])
|
const logs = ref<Array<{ t: number; event: string; payload: string }>>([])
|
||||||
|
|
||||||
const defaultSubscribeTopics = [
|
const defaultSubscribeTopics = [
|
||||||
'period.tick',
|
'period.tick',
|
||||||
|
'user.streak',
|
||||||
'period.opened',
|
'period.opened',
|
||||||
'period.locked',
|
'period.locked',
|
||||||
'period.payout',
|
'period.payout',
|
||||||
@@ -71,6 +107,17 @@ const defaultSubscribeTopics = [
|
|||||||
'auto.spin.progress',
|
'auto.spin.progress',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const testPlayerOddsSourceLabel = computed(() => {
|
||||||
|
const source = testPlayerOdds.value?.source
|
||||||
|
if (source === 'db_user') {
|
||||||
|
return t('test.ws.test_source_db')
|
||||||
|
}
|
||||||
|
if (source === 'synthetic') {
|
||||||
|
return t('test.ws.test_source_synthetic')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
const logText = computed(() => {
|
const logText = computed(() => {
|
||||||
if (!logs.value.length) return t('test.ws.log_empty')
|
if (!logs.value.length) return t('test.ws.log_empty')
|
||||||
return logs.value
|
return logs.value
|
||||||
@@ -109,6 +156,20 @@ async function loadConfig() {
|
|||||||
} else {
|
} else {
|
||||||
subscribeTopics.value = [...defaultSubscribeTopics]
|
subscribeTopics.value = [...defaultSubscribeTopics]
|
||||||
}
|
}
|
||||||
|
const rawOddsTopics = res.data.odds_push_topics
|
||||||
|
if (Array.isArray(rawOddsTopics)) {
|
||||||
|
oddsPushTopics.value = rawOddsTopics.filter((x: unknown): x is string => typeof x === 'string' && x.trim() !== '')
|
||||||
|
} else {
|
||||||
|
oddsPushTopics.value = ['user.streak', 'wallet.changed', 'bet.accepted']
|
||||||
|
}
|
||||||
|
const rawOddsFields = res.data.player_odds_fields
|
||||||
|
if (Array.isArray(rawOddsFields)) {
|
||||||
|
playerOddsFields.value = rawOddsFields.filter((x: unknown): x is string => typeof x === 'string' && x.trim() !== '')
|
||||||
|
} else {
|
||||||
|
playerOddsFields.value = ['current_streak', 'streak_level', 'odds_factor', 'is_jackpot']
|
||||||
|
}
|
||||||
|
const rawTestOdds = res.data.test_player_odds
|
||||||
|
testPlayerOdds.value = rawTestOdds && typeof rawTestOdds === 'object' ? (rawTestOdds as Record<string, unknown>) : null
|
||||||
const firstSample = Array.isArray(res.data.sample_messages) && res.data.sample_messages.length ? String(res.data.sample_messages[0]) : ''
|
const firstSample = Array.isArray(res.data.sample_messages) && res.data.sample_messages.length ? String(res.data.sample_messages[0]) : ''
|
||||||
sendMessage.value = firstSample
|
sendMessage.value = firstSample
|
||||||
ready.value = wsUrl.value !== ''
|
ready.value = wsUrl.value !== ''
|
||||||
@@ -146,10 +207,22 @@ function connectWs() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
|
const raw = typeof event.data === 'string' ? event.data : JSON.stringify(event.data)
|
||||||
|
let eventName = 'ws.message'
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { event?: string; topic?: string }
|
||||||
|
if (typeof parsed.event === 'string' && parsed.event !== '') {
|
||||||
|
eventName = parsed.event
|
||||||
|
} else if (typeof parsed.topic === 'string' && parsed.topic !== '') {
|
||||||
|
eventName = parsed.topic
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep default event name
|
||||||
|
}
|
||||||
appendLog({
|
appendLog({
|
||||||
t: Date.now(),
|
t: Date.now(),
|
||||||
event: 'ws.message',
|
event: eventName,
|
||||||
payload: typeof event.data === 'string' ? event.data : JSON.stringify(event.data),
|
payload: raw,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
socket.onerror = () => {
|
socket.onerror = () => {
|
||||||
@@ -199,6 +272,10 @@ function sendWs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadConfig()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disconnectWs()
|
disconnectWs()
|
||||||
clearLogs()
|
clearLogs()
|
||||||
@@ -217,4 +294,21 @@ onUnmounted(() => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.config-label {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.mr-4 {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.test-odds-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.ml-8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user