1.重构websocket连接

This commit is contained in:
2026-05-27 10:28:39 +08:00
parent a7c2a29764
commit 8f5ba977a4
12 changed files with 1101 additions and 144 deletions

View File

@@ -40,6 +40,33 @@ final class WebSocketConfigHelper
return 'ws://127.0.0.1:3131/ws/';
}
/**
* 在基础 ws_url 上拼接握手鉴权 Query
* - 后台用auth_token + admin_ws_token可观测全量主题无 user_id 过滤)
* - H5 用:调用方传 user_token与 auth_token 一起拼上去
*
* @param array{auth_token?: string, user_token?: string, admin_ws_token?: string} $tokens
*/
public static function appendTokensToWsUrl(string $wsUrl, array $tokens): string
{
$wsUrl = trim($wsUrl);
if ($wsUrl === '') {
return $wsUrl;
}
$pairs = [];
foreach (['auth_token', 'user_token', 'admin_ws_token'] as $key) {
$val = isset($tokens[$key]) && is_string($tokens[$key]) ? trim($tokens[$key]) : '';
if ($val !== '') {
$pairs[] = $key . '=' . rawurlencode($val);
}
}
if ($pairs === []) {
return $wsUrl;
}
$sep = str_contains($wsUrl, '?') ? '&' : '?';
return $wsUrl . $sep . implode('&', $pairs);
}
private static function isLoopbackWsUrl(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\facade\Token;
use app\common\library\Auth;
use support\Redis;
use Throwable;
/**
* WebSocket 握手鉴权助手(与 HTTP §1.3 对齐):
*
* 两种合法身份:
* 1) **mobileH5/移动端)**URL Query 必须带 `auth_token` + `user_token`,校验通过后绑定 user_id
* 分发器对 user 级主题bet.win 等)按 user_id 过滤,只发本人。
* 2) **admin后台联调/实时对局页)**URL Query 必须带 `auth_token` + `admin_ws_token`
* `admin_ws_token` 由后台 `wsConfig` 接口签发并写入 Redis短时签名。绑定 user_id=0
* 分发器对该连接不做 user 级过滤,可观测全量推送(用于运维/联调)。
*
* 任一身份通过即可建连;都不满足则拒绝握手。
*
* 返回结构:
* [
* 'ok' => bool,
* 'user_id' => int,
* 'mode' => 'mobile' | 'admin' | '',
* 'admin_id'=> int,
* 'reason' => string,
* 'auth_token' => string,
* 'user_token' => string,
* 'admin_ws_token' => string,
* ]
*/
final class GameWebSocketAuthHelper
{
/** admin_ws_token 在 Redis 中的 key 前缀value 存 admin_idTTL 由 issueAdminWsToken 决定 */
private const ADMIN_TOKEN_REDIS_PREFIX = 'dfw:v1:ws:admin_token:';
private const ADMIN_TOKEN_DEFAULT_TTL = 7200;
/**
* @param array<string, mixed> $query 解析后的 URL Query 参数
* @return array{ok:bool, user_id:int, mode:string, admin_id:int, reason:string, auth_token:string, user_token:string, admin_ws_token:string}
*/
public static function authorize(array $query): array
{
$authToken = self::pickFirstString($query, ['auth_token', 'auth-token', 'authToken']);
$userToken = self::pickFirstString($query, ['user_token', 'user-token', 'userToken', 'token']);
$adminWsToken = self::pickFirstString($query, ['admin_ws_token', 'admin-ws-token', 'adminWsToken']);
// ===== Admin 旁路:只校验 admin_ws_token由后台 wsConfig 签发,已隐含管理员身份) =====
if ($adminWsToken !== '') {
$adminId = self::validateAdminWsToken($adminWsToken);
if ($adminId > 0) {
return [
'ok' => true,
'user_id' => 0,
'mode' => 'admin',
'admin_id' => $adminId,
'reason' => '',
'auth_token' => $authToken,
'user_token' => $userToken,
'admin_ws_token' => $adminWsToken,
];
}
return self::deny('admin-ws-token invalid or expired', $authToken, $userToken, $adminWsToken);
}
// ===== MobileH5必须同时校验 auth-token + user-token =====
if ($authToken === '') {
return self::deny('missing auth-token', '', $userToken, '');
}
$authData = Token::get($authToken);
if (!is_array($authData) || ($authData['type'] ?? '') !== 'auth-token') {
return self::deny('invalid auth-token type', $authToken, $userToken, '');
}
$authExpire = filter_var($authData['expire_time'] ?? 0, FILTER_VALIDATE_INT);
if ($authExpire === false || $authExpire < time()) {
return self::deny('auth-token expired', $authToken, $userToken, '');
}
if ($userToken === '') {
return self::deny('missing user-token', $authToken, '', '');
}
$userData = Token::get($userToken);
if (!is_array($userData) || ($userData['type'] ?? '') !== Auth::TOKEN_TYPE) {
return self::deny('invalid user-token type', $authToken, $userToken, '');
}
$userExpire = filter_var($userData['expire_time'] ?? 0, FILTER_VALIDATE_INT);
if ($userExpire === false || $userExpire < time()) {
return self::deny('user-token expired', $authToken, $userToken, '');
}
$userId = filter_var($userData['user_id'] ?? 0, FILTER_VALIDATE_INT);
if ($userId === false || $userId <= 0) {
return self::deny('user-token has no user_id', $authToken, $userToken, '');
}
return [
'ok' => true,
'user_id' => (int) $userId,
'mode' => 'mobile',
'admin_id' => 0,
'reason' => '',
'auth_token' => $authToken,
'user_token' => $userToken,
'admin_ws_token' => '',
];
}
/**
* 为已登录的后台管理员签发短时 admin-ws-token返回 [token, ttl]。
* 调用方app/admin/controller/test/GameCurrentStatus::wsConfig、app/admin/controller/game/Live::wsConfig
*/
public static function issueAdminWsToken(int $adminId, ?int $ttl = null): array
{
if ($adminId <= 0) {
return ['token' => '', 'ttl' => 0];
}
$ttl = ($ttl !== null && $ttl > 0) ? $ttl : self::ADMIN_TOKEN_DEFAULT_TTL;
try {
$token = bin2hex(random_bytes(20));
} catch (Throwable) {
$token = md5(uniqid('admin_ws_', true) . microtime(true) . random_int(0, PHP_INT_MAX));
}
try {
Redis::setEx(self::ADMIN_TOKEN_REDIS_PREFIX . $token, $ttl, (string) $adminId);
} catch (Throwable) {
return ['token' => '', 'ttl' => 0];
}
return ['token' => $token, 'ttl' => $ttl];
}
/**
* 校验 admin-ws-token返回 admin_id>0 表示有效0 表示无效/过期。
*/
public static function validateAdminWsToken(string $token): int
{
$token = trim($token);
if ($token === '' || strlen($token) > 96) {
return 0;
}
try {
$raw = Redis::get(self::ADMIN_TOKEN_REDIS_PREFIX . $token);
} catch (Throwable) {
return 0;
}
if ($raw === false || $raw === null || $raw === '') {
return 0;
}
$adminId = filter_var($raw, FILTER_VALIDATE_INT);
return $adminId === false ? 0 : (int) $adminId;
}
/**
* 从 ws header 中解析 GET 行 QueryWorkerman 在 onWebSocketConnect($connection, $request) 时
* $request 可能为字符串或对象;为兼容,这里允许直接传 URI Query 字符串)。
*
* @return array<string, string>
*/
public static function parseQueryString(string $queryString): array
{
$queryString = trim($queryString);
if ($queryString === '') {
return [];
}
if ($queryString[0] === '?') {
$queryString = substr($queryString, 1);
}
$out = [];
parse_str($queryString, $out);
$clean = [];
foreach ($out as $k => $v) {
if (!is_string($k)) {
continue;
}
if (is_string($v)) {
$clean[$k] = $v;
} elseif (is_scalar($v)) {
$clean[$k] = (string) $v;
}
}
return $clean;
}
/**
* @param array<string, mixed> $query
* @param list<string> $keys
*/
private static function pickFirstString(array $query, array $keys): string
{
foreach ($keys as $k) {
if (!isset($query[$k])) {
continue;
}
$v = $query[$k];
if (!is_scalar($v)) {
continue;
}
$s = trim((string) $v);
if ($s !== '') {
return $s;
}
}
return '';
}
/**
* @return array{ok:bool, user_id:int, mode:string, admin_id:int, reason:string, auth_token:string, user_token:string, admin_ws_token:string}
*/
private static function deny(string $reason, string $authToken, string $userToken, string $adminWsToken): array
{
return [
'ok' => false,
'user_id' => 0,
'mode' => '',
'admin_id' => 0,
'reason' => $reason,
'auth_token' => $authToken,
'user_token' => $userToken,
'admin_ws_token' => $adminWsToken,
];
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\Log;
use Throwable;
use Workerman\Connection\TcpConnection;
/**
* WebSocket 事件分发器(仅 gameWebSocketServer 进程内调用)。
*
* 职责:
* 1. 从 GameWebSocketEventBus 队列消费事件
* 2. 按 topic 反向索引取出候选 connection_id
* 3. user 级主题bet.win / user.streak / wallet.changed / bet.accepted 等)按
* data.user_id 与连接绑定 user_id 比对,仅命中本人才下发
* 4. 每一步都打 ws 日志,便于排查"为什么没收到推送"
*/
final class GameWebSocketDispatcher
{
/**
* 这些 topic 的 data.user_id 必须等于连接绑定的 user_id 才会下发;
* 其它 topicperiod.tick / period.opened / jackpot.hit / admin.* 等)一律广播给订阅者。
*
* 与 docs/36字花-移动端接口设计草案.md §7.1.2A 保持一致。
*
* @var list<string>
*/
private const USER_SCOPED_TOPICS = [
'bet.win',
'user.streak',
'wallet.changed',
'bet.accepted',
'auto.spin.progress',
];
/**
* 分发单条事件到所有命中的连接。
*
* @param array{topic:string, event:string, data:array<string,mixed>, server_time:int} $event
* @param array<int, TcpConnection> $connections connection_id => TcpConnection
*/
public static function dispatch(array $event, array $connections): void
{
$topic = $event['topic'] ?? '';
if (!is_string($topic) || $topic === '') {
return;
}
$candidateIds = GameWebSocketSubscriptionRegistry::connectionsForTopic($topic);
if ($candidateIds === []) {
Log::channel('ws')->debug('dispatch skip: no subscriber', [
'topic' => $topic,
'queue_server_time' => $event['server_time'] ?? 0,
]);
return;
}
$userScoped = in_array($topic, self::USER_SCOPED_TOPICS, true);
$payloadUserId = 0;
if ($userScoped) {
$raw = $event['data']['user_id'] ?? 0;
$parsed = filter_var($raw, FILTER_VALIDATE_INT);
$payloadUserId = $parsed === false ? 0 : (int) $parsed;
}
$frame = json_encode([
'event' => $event['event'] ?? $topic,
'topic' => $topic,
'data' => $event['data'] ?? [],
'server_time' => $event['server_time'] ?? time(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($frame) || $frame === '') {
Log::channel('ws')->warning('dispatch skip: invalid json frame', [
'topic' => $topic,
]);
return;
}
$matched = 0;
$skippedNotOwner = 0;
$skippedClosed = 0;
$sendFailed = 0;
foreach ($candidateIds as $cid) {
if (!isset($connections[$cid])) {
$skippedClosed++;
continue;
}
if ($userScoped && $payloadUserId > 0) {
$boundUid = GameWebSocketSubscriptionRegistry::userIdOf($cid);
if ($boundUid !== $payloadUserId) {
$skippedNotOwner++;
continue;
}
}
try {
$connections[$cid]->send($frame);
$matched++;
} catch (Throwable $e) {
$sendFailed++;
Log::channel('ws')->warning('dispatch send failed', [
'topic' => $topic,
'connection_id' => $cid,
'error' => $e->getMessage(),
]);
}
}
Log::channel('ws')->info('dispatch', [
'topic' => $topic,
'user_scoped' => $userScoped,
'payload_user_id' => $payloadUserId,
'candidates' => count($candidateIds),
'matched' => $matched,
'skipped_not_owner' => $skippedNotOwner,
'skipped_closed' => $skippedClosed,
'send_failed' => $sendFailed,
'frame_size' => strlen($frame),
]);
}
/**
* 直接向某连接下发单帧(握手回执 / 订阅回执 / pong / 演示帧)。
*/
public static function sendDirect(TcpConnection $connection, string $event, array $data, string $tag = ''): void
{
$frame = json_encode(array_merge([
'event' => $event,
], $data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($frame) || $frame === '') {
return;
}
try {
$connection->send($frame);
Log::channel('ws')->debug('direct send', [
'connection_id' => $connection->id,
'event' => $event,
'tag' => $tag,
'frame_size' => strlen($frame),
]);
} catch (Throwable $e) {
Log::channel('ws')->warning('direct send failed', [
'connection_id' => $connection->id,
'event' => $event,
'tag' => $tag,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -10,6 +10,8 @@ use Throwable;
/**
* 通过 Redis 列表在不同进程间投递 WebSocket 事件。
*
* 入队失败统一返回 false 并写 ws 日志runtime/logs/ws.log便于排查"为什么没有推送"。
*/
final class GameWebSocketEventBus
{
@@ -18,7 +20,7 @@ final class GameWebSocketEventBus
/**
* @param array<string, mixed> $data
* @return bool 是否成功入队false 表示 Redis 不可用或参数非法,调用方应避免标记已推送
* @return bool 是否成功入队false 表示 Redis 不可用或参数非法,调用方应避免标记"已推送"
*/
public static function publish(string $topic, array $data): bool
{
@@ -34,12 +36,34 @@ final class GameWebSocketEventBus
];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json) || $json === '') {
Log::channel('ws')->warning('publish skip: invalid json payload', [
'topic' => $topic,
]);
return false;
}
try {
$len = Redis::lPush(self::KEY_QUEUE, $json);
return is_numeric($len) && (int) $len > 0;
$ok = is_numeric($len) && (int) $len > 0;
if ($ok) {
$uid = filter_var($data['user_id'] ?? 0, FILTER_VALIDATE_INT);
Log::channel('ws')->info('publish', [
'topic' => $topic,
'user_id' => $uid === false ? 0 : (int) $uid,
'queue_len_after' => (int) $len,
'payload_size' => strlen($json),
]);
} else {
Log::channel('ws')->warning('publish lpush returned non-positive', [
'topic' => $topic,
'returned' => $len,
]);
}
return $ok;
} catch (Throwable $e) {
Log::channel('ws')->error('publish failed (redis exception)', [
'topic' => $topic,
'error' => $e->getMessage(),
]);
Log::warning('ws event bus publish failed', [
'topic' => $topic,
'error' => $e->getMessage(),
@@ -92,10 +116,27 @@ final class GameWebSocketEventBus
'server_time' => $serverTime,
];
}
} catch (Throwable) {
} catch (Throwable $e) {
Log::channel('ws')->error('popBatch failed (redis exception)', [
'error' => $e->getMessage(),
'popped' => count($out),
]);
return $out;
}
return $out;
}
/**
* 当前队列堆积长度(监控用)。
*/
public static function queueLength(): int
{
try {
$len = Redis::lLen(self::KEY_QUEUE);
return is_numeric($len) ? (int) $len : 0;
} catch (Throwable) {
return -1;
}
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 进程内 WebSocket 订阅注册表(仅在 gameWebSocketServer 单进程内使用)。
*
* - 维护双向索引:
* - topic => map<connection_id, true>
* - connection => [ topics: list<string>, user_id: int, last_seen_at: int, remote_ip: string ]
* - 分发器按 (topic) 直接拿到候选连接列表,按 (data.user_id) 过滤后再 send避免 O(N) 全遍历。
* - **必须** 与 GameWebSocketServer 同进程使用count=1不可水平扩展多 worker 间无法共享连接)。
*
* 该类不持有 TcpConnection 引用,仅持有 connection_id 与元数据Server 维护 connection_id => TcpConnection 映射。
*/
final class GameWebSocketSubscriptionRegistry
{
/** @var array<string, array<int, true>> topic => { connection_id: true } */
private static array $topicIndex = [];
/** @var array<int, array{topics: list<string>, user_id: int, last_seen_at: int, remote_ip: string}> */
private static array $connectionMeta = [];
/**
* 注册新连接onConnect 调用)。
*/
public static function registerConnection(int $connectionId, int $userId, string $remoteIp = ''): void
{
if ($connectionId <= 0) {
return;
}
self::$connectionMeta[$connectionId] = [
'topics' => [],
'user_id' => max(0, $userId),
'last_seen_at' => time(),
'remote_ip' => $remoteIp,
];
}
/**
* 注销连接onClose 调用):从所有 topic 索引中移除该 connection。
*/
public static function unregisterConnection(int $connectionId): void
{
if ($connectionId <= 0 || !isset(self::$connectionMeta[$connectionId])) {
return;
}
foreach (self::$connectionMeta[$connectionId]['topics'] as $topic) {
if (isset(self::$topicIndex[$topic][$connectionId])) {
unset(self::$topicIndex[$topic][$connectionId]);
if (self::$topicIndex[$topic] === []) {
unset(self::$topicIndex[$topic]);
}
}
}
unset(self::$connectionMeta[$connectionId]);
}
/**
* 替换该连接的订阅列表subscribe 报文调用,覆盖式订阅,符合现网协议)。
*
* @param list<string> $topics
* @return list<string> 实际生效的、去重排序后的订阅列表
*/
public static function replaceSubscriptions(int $connectionId, array $topics): array
{
if ($connectionId <= 0 || !isset(self::$connectionMeta[$connectionId])) {
return [];
}
foreach (self::$connectionMeta[$connectionId]['topics'] as $oldTopic) {
if (isset(self::$topicIndex[$oldTopic][$connectionId])) {
unset(self::$topicIndex[$oldTopic][$connectionId]);
if (self::$topicIndex[$oldTopic] === []) {
unset(self::$topicIndex[$oldTopic]);
}
}
}
$clean = [];
foreach ($topics as $t) {
if (!is_string($t)) {
continue;
}
$v = trim($t);
if ($v === '' || strlen($v) > 64) {
continue;
}
$clean[$v] = true;
}
$finalTopics = array_keys($clean);
sort($finalTopics);
self::$connectionMeta[$connectionId]['topics'] = $finalTopics;
foreach ($finalTopics as $topic) {
self::$topicIndex[$topic][$connectionId] = true;
}
return $finalTopics;
}
/**
* 获取订阅了指定 topic 的所有 connection_id。
*
* @return list<int>
*/
public static function connectionsForTopic(string $topic): array
{
$topic = trim($topic);
if ($topic === '' || !isset(self::$topicIndex[$topic])) {
return [];
}
return array_keys(self::$topicIndex[$topic]);
}
/**
* 标记连接活跃时间(接收任意消息时调用,用于心跳超时判断)。
*/
public static function touch(int $connectionId): void
{
if (isset(self::$connectionMeta[$connectionId])) {
self::$connectionMeta[$connectionId]['last_seen_at'] = time();
}
}
/**
* @return array{topics: list<string>, user_id: int, last_seen_at: int, remote_ip: string}|null
*/
public static function meta(int $connectionId): ?array
{
return self::$connectionMeta[$connectionId] ?? null;
}
public static function userIdOf(int $connectionId): int
{
return self::$connectionMeta[$connectionId]['user_id'] ?? 0;
}
/**
* 找出所有 last_seen_at 早于 $cutoff 的连接 id用于服务端主动关闭僵尸连接
*
* @return list<int>
*/
public static function staleConnections(int $cutoff): array
{
$out = [];
foreach (self::$connectionMeta as $cid => $meta) {
if (($meta['last_seen_at'] ?? 0) < $cutoff) {
$out[] = $cid;
}
}
return $out;
}
/**
* 当前活跃连接数(运维/诊断用)。
*/
public static function connectionCount(): int
{
return count(self::$connectionMeta);
}
/**
* 当前活跃订阅总数(运维/诊断用)。
*/
public static function subscriptionCount(): int
{
$sum = 0;
foreach (self::$topicIndex as $conns) {
$sum += count($conns);
}
return $sum;
}
/**
* 仅供测试/进程重启时清空索引。
*/
public static function reset(): void
{
self::$topicIndex = [];
self::$connectionMeta = [];
}
}