diff --git a/app/admin/controller/test/PushGamePeriod.php b/app/admin/controller/test/PushGamePeriod.php new file mode 100644 index 0000000..5406c0f --- /dev/null +++ b/app/admin/controller/test/PushGamePeriod.php @@ -0,0 +1,32 @@ +initializeBackend($request); + if ($response !== null) { + return $response; + } + + return $this->success('', [ + 'url' => PushChannelConfigHelper::wsBaseUrl(), + 'app_key' => PushChannelConfigHelper::appKey(), + 'channel' => 'public-game-period', + ]); + } +} diff --git a/app/admin/controller/test/PushOperationNotice.php b/app/admin/controller/test/PushOperationNotice.php new file mode 100644 index 0000000..b4c0618 --- /dev/null +++ b/app/admin/controller/test/PushOperationNotice.php @@ -0,0 +1,32 @@ +initializeBackend($request); + if ($response !== null) { + return $response; + } + + return $this->success('', [ + 'url' => PushChannelConfigHelper::wsBaseUrl(), + 'app_key' => PushChannelConfigHelper::appKey(), + 'channel' => 'public-operation-notice', + ]); + } +} diff --git a/app/admin/controller/test/PushPrivateUser.php b/app/admin/controller/test/PushPrivateUser.php new file mode 100644 index 0000000..110ae2e --- /dev/null +++ b/app/admin/controller/test/PushPrivateUser.php @@ -0,0 +1,40 @@ +initializeBackend($request); + if ($response !== null) { + return $response; + } + + $uuid = trim((string) ($request->get('uuid') ?? $request->post('uuid') ?? '')); + if ($uuid === '') { + return $this->error(__('Parameter %s can not be empty', ['uuid'])); + } + if (strlen($uuid) > 64 || !preg_match('/^[0-9a-zA-Z_-]+$/', $uuid)) { + return $this->error(__('Parameter error')); + } + + return $this->success('', [ + 'url' => PushChannelConfigHelper::wsBaseUrl(), + 'app_key' => PushChannelConfigHelper::appKey(), + 'channel' => 'private-user-' . $uuid, + ]); + } +} diff --git a/app/common/library/admin/PushChannelConfigHelper.php b/app/common/library/admin/PushChannelConfigHelper.php new file mode 100644 index 0000000..46dc5cf --- /dev/null +++ b/app/common/library/admin/PushChannelConfigHelper.php @@ -0,0 +1,24 @@ + false, 'msg' => $e->getMessage()]; } + self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now); + self::publishSnapshot(null); return [ 'ok' => true, @@ -227,6 +235,7 @@ final class GameLiveService 'update_time' => time(), ]); $record['status'] = 1; + self::publishPublicPeriodLocked($record); } if ($elapsed < $periodSeconds) { return; @@ -238,16 +247,121 @@ final class GameLiveService { try { $payload = self::buildSnapshot($recordId); - $api = new Api( - str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')), - (string) config('plugin.webman.push.app.app_key'), - (string) config('plugin.webman.push.app.app_secret') - ); + $api = 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') + ); + } + + /** + * 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义) + */ + private static function publishPublicPeriodTick(array $snapshot, Api $api): void + { + $record = $snapshot['record'] ?? null; + $serverTime = (int) ($snapshot['server_time'] ?? time()); + $remaining = (int) ($snapshot['remaining_seconds'] ?? 0); + $betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0); + $periodNo = ''; + $dbStatus = 0; + $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, + ]; + 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; + } + } + if ($resultNumber !== null) { + $payload['result_number'] = $resultNumber; + } + $api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_TICK, $payload); + } + + /** + * @param array $record game_record 行 + */ + 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) { + } + } + + 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) { + } + } + + /** + * 与文档 3.1/4.1 中 status 字符串对齐:betting / locked / settling / finished + */ + private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string + { + if ($dbStatus === 0) { + return $betCloseIn > 0 ? 'betting' : 'locked'; + } + if ($dbStatus === 1) { + return 'locked'; + } + if ($dbStatus === 4) { + return 'finished'; + } + if ($dbStatus === 2 || $dbStatus === 3) { + return 'settling'; + } + + return 'finished'; + } + private static function resolveRecord(?int $recordId): ?array { if ($recordId !== null && $recordId > 0) { diff --git a/web/src/lang/autoload.ts b/web/src/lang/autoload.ts index f3ba371..3c81430 100644 --- a/web/src/lang/autoload.ts +++ b/web/src/lang/autoload.ts @@ -9,4 +9,8 @@ export default { '/': ['./frontend/${lang}/index.ts'], [adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'], [adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'], + /** 推送测试三页:共享 test.push.* 文案(见 PushChannelTestPage.vue) */ + [adminBaseRoutePath + '/test/pushGamePeriod']: ['./backend/${lang}/test/push.ts'], + [adminBaseRoutePath + '/test/pushOperationNotice']: ['./backend/${lang}/test/push.ts'], + [adminBaseRoutePath + '/test/pushPrivateUser']: ['./backend/${lang}/test/push.ts'], } diff --git a/web/src/lang/backend/en/test/push.ts b/web/src/lang/backend/en/test/push.ts new file mode 100644 index 0000000..83ddcd9 --- /dev/null +++ b/web/src/lang/backend/en/test/push.ts @@ -0,0 +1,12 @@ +export default { + connected: 'Push connected (subscribed)', + disconnected: 'Disconnected or idle', + user_uuid: 'User uuid', + user_uuid_placeholder: '10-char public id as in mobile profile', + btn_connect: 'Connect & subscribe', + btn_disconnect: 'Disconnect', + btn_clear: 'Clear log', + channel_label: 'Channel', + log_title: 'Event log', + log_empty: 'No messages yet. Ensure the push worker is running and the server publishes to this channel.', +} diff --git a/web/src/lang/backend/en/test/pushGamePeriod.ts b/web/src/lang/backend/en/test/pushGamePeriod.ts new file mode 100644 index 0000000..86233ba --- /dev/null +++ b/web/src/lang/backend/en/test/pushGamePeriod.ts @@ -0,0 +1,3 @@ +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.', +} diff --git a/web/src/lang/backend/en/test/pushOperationNotice.ts b/web/src/lang/backend/en/test/pushOperationNotice.ts new file mode 100644 index 0000000..76ce27c --- /dev/null +++ b/web/src/lang/backend/en/test/pushOperationNotice.ts @@ -0,0 +1,3 @@ +export default { + tip: 'Subscribe to public-operation-notice for broadcast notices such as notice.popout. The server must publish to this channel.', +} diff --git a/web/src/lang/backend/en/test/pushPrivateUser.ts b/web/src/lang/backend/en/test/pushPrivateUser.ts new file mode 100644 index 0000000..8860064 --- /dev/null +++ b/web/src/lang/backend/en/test/pushPrivateUser.ts @@ -0,0 +1,3 @@ +export default { + tip: 'Subscribe to private-user-{uuid}: enter the player uuid; auth uses /plugin/webman/push/auth like the mobile client. For bet.accepted, wallet.changed, etc.', +} diff --git a/web/src/lang/backend/zh-cn/test/push.ts b/web/src/lang/backend/zh-cn/test/push.ts new file mode 100644 index 0000000..d06cd57 --- /dev/null +++ b/web/src/lang/backend/zh-cn/test/push.ts @@ -0,0 +1,12 @@ +export default { + connected: '推送服务已连接(已订阅频道)', + disconnected: '未连接或已断开', + user_uuid: '用户 uuid', + user_uuid_placeholder: '与移动端档案一致的 10 位对外标识', + btn_connect: '连接并订阅', + btn_disconnect: '断开', + btn_clear: '清空日志', + channel_label: '当前频道', + log_title: '事件日志', + log_empty: '暂无推送,请确认 push 进程已启动且服务端会向对应频道发消息。', +} diff --git a/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts b/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts new file mode 100644 index 0000000..a9ea69e --- /dev/null +++ b/web/src/lang/backend/zh-cn/test/pushGamePeriod.ts @@ -0,0 +1,3 @@ +export default { + tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened 等公共事件(需服务端向该频道推送)。', +} diff --git a/web/src/lang/backend/zh-cn/test/pushOperationNotice.ts b/web/src/lang/backend/zh-cn/test/pushOperationNotice.ts new file mode 100644 index 0000000..6075fb4 --- /dev/null +++ b/web/src/lang/backend/zh-cn/test/pushOperationNotice.ts @@ -0,0 +1,3 @@ +export default { + tip: '订阅文档中的「公告广播频道」public-operation-notice,用于验证 notice.popout 等全站公告类推送(需服务端向该频道推送)。', +} diff --git a/web/src/lang/backend/zh-cn/test/pushPrivateUser.ts b/web/src/lang/backend/zh-cn/test/pushPrivateUser.ts new file mode 100644 index 0000000..8f7ae37 --- /dev/null +++ b/web/src/lang/backend/zh-cn/test/pushPrivateUser.ts @@ -0,0 +1,3 @@ +export default { + tip: '订阅「用户私有频道」private-user-{uuid}:请输入玩家 uuid,连接后将走 /plugin/webman/push/auth 鉴权(与移动端一致)。用于验证 bet.accepted、wallet.changed 等私有事件。', +} diff --git a/web/src/utils/backend/pushChannelTest.ts b/web/src/utils/backend/pushChannelTest.ts new file mode 100644 index 0000000..6ce4aa0 --- /dev/null +++ b/web/src/utils/backend/pushChannelTest.ts @@ -0,0 +1,79 @@ +/** + * 后台推送频道测试:加载官方 push.js 并订阅频道、监听文档约定事件 + */ + +const DOC_EVENTS = [ + 'period.tick', + 'period.locked', + 'period.opened', + 'bet.accepted', + 'wallet.changed', + 'notice.popout', + 'withdraw.review_required', +] + +export async function loadPushJs(): Promise { + if ((window as any).Push) { + return + } + await new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = '/plugin/webman/push/push.js' + script.onload = () => resolve() + script.onerror = () => reject(new Error('load push.js failed')) + document.head.appendChild(script) + }) +} + +export type PushTestLogLine = { t: number; event: string; payload: string } + +export function startPushChannelListener(options: { + url: string + app_key: string + channel: string + /** 私有频道需走 /plugin/webman/push/auth */ + usePrivateAuth: boolean + onLog: (line: PushTestLogLine) => void + onConnected: (ok: boolean) => void +}): { disconnect: () => void } { + const PushCtor = (window as any).Push + const cfg: anyObj = { url: options.url, app_key: options.app_key } + if (options.usePrivateAuth) { + cfg.auth = '/plugin/webman/push/auth' + } + const client = new PushCtor(cfg) + const ch = client.subscribe(options.channel) + + const pushLog = (event: string, data: unknown) => { + let payload = '' + try { + payload = typeof data === 'string' ? data : JSON.stringify(data) + } catch { + payload = String(data) + } + options.onLog({ t: Date.now(), event, payload }) + } + + ch.on('pusher:subscription_succeeded', () => { + options.onConnected(true) + pushLog('pusher:subscription_succeeded', {}) + }) + + for (const ev of DOC_EVENTS) { + ch.on(ev, (data: unknown) => { + options.onConnected(true) + pushLog(ev, data) + }) + } + + return { + disconnect: () => { + try { + client.disconnect() + } catch { + /* ignore */ + } + options.onConnected(false) + }, + } +} diff --git a/web/src/views/backend/test/components/PushChannelTestPage.vue b/web/src/views/backend/test/components/PushChannelTestPage.vue new file mode 100644 index 0000000..f5122ef --- /dev/null +++ b/web/src/views/backend/test/components/PushChannelTestPage.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/web/src/views/backend/test/pushGamePeriod/index.vue b/web/src/views/backend/test/pushGamePeriod/index.vue new file mode 100644 index 0000000..e71deb4 --- /dev/null +++ b/web/src/views/backend/test/pushGamePeriod/index.vue @@ -0,0 +1,10 @@ + + + diff --git a/web/src/views/backend/test/pushOperationNotice/index.vue b/web/src/views/backend/test/pushOperationNotice/index.vue new file mode 100644 index 0000000..e14551f --- /dev/null +++ b/web/src/views/backend/test/pushOperationNotice/index.vue @@ -0,0 +1,10 @@ + + + diff --git a/web/src/views/backend/test/pushPrivateUser/index.vue b/web/src/views/backend/test/pushPrivateUser/index.vue new file mode 100644 index 0000000..6b6ac73 --- /dev/null +++ b/web/src/views/backend/test/pushPrivateUser/index.vue @@ -0,0 +1,14 @@ + + +