From 2caf7171312d6a40f4410c1eff4b55246cdca1fe Mon Sep 17 00:00:00 2001 From: HotelSync Date: Fri, 27 Mar 2026 13:53:55 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20NetUP=20via=20agent=20=E2=80=94=20proxy?= =?UTF-8?q?=20HTTP=20requests=20through=20Windows=20workstation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 044: add connection_type + agent_workstation_id to netup_settings - Backend: netupReqViaCfg routes requests via agent WebSocket when connection_type=agent - Agent: add netup_request command handler (proxies HTTP to local NetUP server) - TvWelcomePage: add connection mode toggle (direct / via agent) + workstation selector - Hint changes: URL placeholder shows local IP when agent mode selected Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/044_netup_agent.sql | 4 + backend/src/routes/netup.ts | 185 ++++++++++++++++++------- src/lib/api.ts | 4 +- src/pages/TvWelcomePage.tsx | 84 +++++++++-- 4 files changed, 218 insertions(+), 59 deletions(-) create mode 100644 backend/migrations/044_netup_agent.sql diff --git a/backend/migrations/044_netup_agent.sql b/backend/migrations/044_netup_agent.sql new file mode 100644 index 0000000..f27fb97 --- /dev/null +++ b/backend/migrations/044_netup_agent.sql @@ -0,0 +1,4 @@ +-- NetUP connection via agent +ALTER TABLE netup_settings + ADD COLUMN IF NOT EXISTS connection_type VARCHAR(10) NOT NULL DEFAULT 'direct', + ADD COLUMN IF NOT EXISTS agent_workstation_id UUID REFERENCES workstations(id) ON DELETE SET NULL; diff --git a/backend/src/routes/netup.ts b/backend/src/routes/netup.ts index ff1b921..42aa31c 100644 --- a/backend/src/routes/netup.ts +++ b/backend/src/routes/netup.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { sendCommandAndWait } from '../agent-ws' type SlugParam = { Params: { slug: string } } type SlugIdParam = { Params: { slug: string; id: string } } @@ -53,8 +54,8 @@ const netup: FastifyPluginAsync = async (fastify) => { const canAccess = (userSlug: string | null, role: string, slug: string) => role === 'super_admin' || userSlug === slug - // ── Вспомогательная функция: HTTP запрос к NetUP MW ────────────────────── - async function netupReq( + // ── HTTP запрос к NetUP MW напрямую ────────────────────────────────────── + async function netupReqDirect( serverUrl: string, username: string, password: string, @@ -68,20 +69,53 @@ const netup: FastifyPluginAsync = async (fastify) => { const res = await fetch(url, { method, - headers: { - 'Authorization': `Basic ${cred}`, - 'Content-Type': 'application/json', - }, + headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(8000), }) let data: unknown = null try { data = await res.json() } catch { /* empty response */ } - return { ok: res.ok, status: res.status, data } } + // ── HTTP запрос через агент ─────────────────────────────────────────────── + async function netupReqViaAgent( + workstationId: string, + serverUrl: string, + username: string, + password: string, + method: string, + path: string, + body?: unknown, + ): Promise<{ ok: boolean; status: number; data: unknown }> { + const base = serverUrl.replace(/\/$/, '') + const url = `${base}/mw/api${path}` + const cred = Buffer.from(`${username}:${password}`).toString('base64') + + const result = await sendCommandAndWait(workstationId, { + type: 'netup_request', + method, + url, + headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' }, + body: body ?? null, + }, 10000) as { ok: boolean; status: number; data: unknown } + return result + } + + // ── Роутинг: прямой или через агент ────────────────────────────────────── + async function netupReq( + cfg: { server_url: string; username: string; password: string; connection_type?: string; agent_workstation_id?: string | null }, + method: string, + path: string, + body?: unknown, + ): Promise<{ ok: boolean; status: number; data: unknown }> { + if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) { + return netupReqViaAgent(cfg.agent_workstation_id, cfg.server_url, cfg.username, cfg.password, method, path, body) + } + return netupReqDirect(cfg.server_url, cfg.username, cfg.password, method, path, body) + } + // ── GET /api/hotels/:slug/netup/settings ──────────────────────────────── fastify.get( '/api/hotels/:slug/netup/settings', @@ -95,15 +129,15 @@ const netup: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( - 'SELECT server_url, username, password, default_language, enabled, tl_token FROM netup_settings WHERE hotel_id = $1', + 'SELECT server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1', [hotelId], ) - return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '' } + return rows[0] ?? { server_url: '', username: 'admin', password: '', default_language: 'ru_RU', enabled: false, tl_token: '', connection_type: 'direct', agent_workstation_id: null } }, ) // ── PATCH /api/hotels/:slug/netup/settings ─────────────────────────────── - fastify.patch( + fastify.patch( '/api/hotels/:slug/netup/settings', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -115,20 +149,23 @@ const netup: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { server_url, username, password, default_language, enabled, tl_token } = request.body + const { server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id } = request.body await db.query( - `INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + `INSERT INTO netup_settings (hotel_id, server_url, username, password, default_language, enabled, tl_token, connection_type, agent_workstation_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) ON CONFLICT (hotel_id) DO UPDATE SET - server_url = EXCLUDED.server_url, - username = EXCLUDED.username, - password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END, - default_language = EXCLUDED.default_language, - enabled = EXCLUDED.enabled, - tl_token = EXCLUDED.tl_token, - updated_at = NOW()`, - [hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? ''], + server_url = EXCLUDED.server_url, + username = EXCLUDED.username, + password = CASE WHEN EXCLUDED.password = '' THEN netup_settings.password ELSE EXCLUDED.password END, + default_language = EXCLUDED.default_language, + enabled = EXCLUDED.enabled, + tl_token = EXCLUDED.tl_token, + connection_type = EXCLUDED.connection_type, + agent_workstation_id = EXCLUDED.agent_workstation_id, + updated_at = NOW()`, + [hotelId, server_url ?? '', username ?? 'admin', password ?? '', default_language ?? 'ru_RU', enabled ?? true, tl_token ?? '', + connection_type ?? 'direct', agent_workstation_id ?? null], ) return { ok: true } }, @@ -147,14 +184,14 @@ const netup: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const { rows } = await db.query( - 'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1', + 'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1', [hotelId], ) const cfg = rows[0] if (!cfg?.server_url) return reply.code(400).send({ error: 'Настройки не заполнены' }) try { - const result = await netupReq(cfg.server_url, cfg.username, cfg.password, 'GET', '/hotel-room') + const result = await netupReq(cfg, 'GET', '/hotel-room') if (result.ok) return { ok: true, message: 'Подключение успешно' } return reply.code(502).send({ error: `NetUP вернул статус ${result.status}` }) } catch (err) { @@ -240,7 +277,7 @@ const netup: FastifyPluginAsync = async (fastify) => { // Get NetUP settings const { rows: [cfg] } = await db.query( - 'SELECT server_url, username, password FROM netup_settings WHERE hotel_id = $1 AND enabled = true', + 'SELECT server_url, username, password, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 AND enabled = true', [hotelId], ) if (!cfg?.server_url) return reply.code(400).send({ error: 'NetUP не настроен' }) @@ -257,7 +294,7 @@ const netup: FastifyPluginAsync = async (fastify) => { try { const result = await netupReq( - cfg.server_url, cfg.username, cfg.password, + cfg, 'POST', '/system-message', { name: guest_name ? `Сообщение для ${guest_name}` : `Сообщение в номер ${mapping.room_number}`, @@ -358,13 +395,45 @@ const netup: FastifyPluginAsync = async (fastify) => { // ── Internal helpers ────────────────────────────────────────────────────────── +async function netupReqViaCfg( + cfg: { server_url: string; username: string; password: string; connection_type: string; agent_workstation_id: string | null }, + method: string, + path: string, + body?: unknown, +): Promise<{ ok: boolean; status: number; data: unknown }> { + if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) { + const base = cfg.server_url.replace(/\/$/, '') + const url = `${base}/mw/api${path}` + const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') + return sendCommandAndWait(cfg.agent_workstation_id, { + type: 'netup_request', + method, + url, + headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' }, + body: body ?? null, + }, 10000) as Promise<{ ok: boolean; status: number; data: unknown }> + } + const base = cfg.server_url.replace(/\/$/, '') + const url = `${base}/mw/api${path}` + const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') + const res = await fetch(url, { + method, + headers: { 'Authorization': `Basic ${cred}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(8000), + }) + let data: unknown = null + try { data = await res.json() } catch { /* empty */ } + return { ok: res.ok, status: res.status, data } +} + async function getNetupCfg(hotelId: string, needEnabled = true) { const cond = needEnabled ? 'AND enabled = true' : '' const { rows: [cfg] } = await db.query( - `SELECT server_url, username, password, default_language FROM netup_settings WHERE hotel_id = $1 ${cond}`, + `SELECT server_url, username, password, default_language, connection_type, agent_workstation_id FROM netup_settings WHERE hotel_id = $1 ${cond}`, [hotelId], ) - return cfg as { server_url: string; username: string; password: string; default_language: string } | undefined + return cfg as { server_url: string; username: string; password: string; default_language: string; connection_type: string; agent_workstation_id: string | null } | undefined } async function getMapping(hotelId: string, roomId: string) { @@ -396,31 +465,41 @@ async function notifyNetupCheckinInternal( const base = cfg.server_url.replace(/\/$/, '') const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in` - const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') // NetUP expects reservation_id as a numeric string (parses with strconv.Atoi) - // Convert UUID to a stable numeric string by taking first 8 hex chars → decimal const resIdStr = String(parseInt(reservationId.replace(/-/g, '').slice(0, 8), 16) % 100000000) const requestBody = { reservation_id: resIdStr, name: guestName, language: language ?? cfg.default_language } try { - const res = await fetch(url, { - method: 'POST', - headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(6000), - }) - const responseText = await res.text().catch(() => '') + let ok: boolean, status: number + let responseText = '' + + if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) { + const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-in`, requestBody) + ok = result.ok; status = result.status + responseText = result.data ? JSON.stringify(result.data) : '' + } else { + const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Basic ${cred}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(6000), + }) + ok = res.ok; status = res.status + responseText = await res.text().catch(() => '') + } + const event: PushEvent = { ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, - status: res.ok ? 'ok' : 'error', httpStatus: res.status, + status: ok ? 'ok' : 'error', httpStatus: status, responseBody: responseText || undefined, } - if (!res.ok) event.error = `HTTP ${res.status}` + if (!ok) event.error = `HTTP ${status}` recordPush(event) - return res.ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${res.status}: ${responseText}` } + return ok ? { ok: true, netupRoom: mapping.netup_room_number } : { ok: false, error: `HTTP ${status}: ${responseText}` } } catch (err) { const error = err instanceof Error ? err.message : 'Ошибка соединения' recordPush({ ts: new Date().toISOString(), action: 'check-in', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, requestBody, status: 'error', error }) @@ -451,21 +530,31 @@ export async function notifyNetupCheckout(hotelId: string, roomId: string) { const base = cfg.server_url.replace(/\/$/, '') const url = `${base}/mw/api/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out` - const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') try { - const res = await fetch(url, { - method: 'POST', - headers: { Authorization: `Basic ${cred}` }, - signal: AbortSignal.timeout(6000), - }) - const responseText = await res.text().catch(() => '') + let ok: boolean, status: number, responseText = '' + + if (cfg.connection_type === 'agent' && cfg.agent_workstation_id) { + const result = await netupReqViaCfg(cfg, 'POST', `/hotel-room/${encodeURIComponent(mapping.netup_room_number)}/check-out`) + ok = result.ok; status = result.status + responseText = result.data ? JSON.stringify(result.data) : '' + } else { + const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64') + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Basic ${cred}` }, + signal: AbortSignal.timeout(6000), + }) + ok = res.ok; status = res.status + responseText = await res.text().catch(() => '') + } + recordPush({ ts: new Date().toISOString(), action: 'check-out', hotelId, roomNumber, netupRoom: mapping.netup_room_number, url, - status: res.ok ? 'ok' : 'error', httpStatus: res.status, + status: ok ? 'ok' : 'error', httpStatus: status, responseBody: responseText || undefined, - error: res.ok ? undefined : `HTTP ${res.status}`, + error: ok ? undefined : `HTTP ${status}`, }) } catch (err) { const error = err instanceof Error ? err.message : 'Ошибка соединения' diff --git a/src/lib/api.ts b/src/lib/api.ts index b8249c0..19bc7a3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -546,10 +546,10 @@ export const api = { // ── NetUP IPTV ──────────────────────────────────────────────────────────── netup: { getSettings: (slug: string) => - req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string }>( + req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string; connectionType: string; agentWorkstationId: string | null }>( 'GET', `/api/hotels/${slug}/netup/settings`), - saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string }) => + saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string; connection_type: string; agent_workstation_id: string | null }) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data), testConnection: (slug: string) => diff --git a/src/pages/TvWelcomePage.tsx b/src/pages/TvWelcomePage.tsx index d0b3ada..3752a75 100644 --- a/src/pages/TvWelcomePage.tsx +++ b/src/pages/TvWelcomePage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback } from 'react' -import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, RefreshCw, Trash2 } from 'lucide-react' +import { Tv2, Plug, Map, Activity, CheckCircle2, XCircle, Loader2, Save, Eye, EyeOff, RefreshCw, Trash2, Wifi, Globe } from 'lucide-react' import { cn } from '../lib/utils' import { api } from '../lib/api' import { useAuth } from '../contexts/AuthContext' +import type { Workstation } from '../lib/api' type Tab = 'connection' | 'rooms' | 'log' @@ -38,11 +39,14 @@ export function TvWelcomePage() { const [showPass, setShowPass] = useState(false) // Connection settings - const [serverUrl, setServerUrl] = useState('') - const [username, setUsername] = useState('admin') - const [password, setPassword] = useState('') - const [language, setLanguage] = useState('ru_RU') - const [enabled, setEnabled] = useState(true) + const [serverUrl, setServerUrl] = useState('') + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('') + const [language, setLanguage] = useState('ru_RU') + const [enabled, setEnabled] = useState(true) + const [connectionType, setConnectionType] = useState<'direct' | 'agent'>('direct') + const [agentWorkstationId, setAgentWorkstationId] = useState(null) + const [workstations, setWorkstations] = useState([]) const [settingsLoaded, setSettingsLoaded] = useState(false) // Room mappings @@ -57,7 +61,7 @@ export function TvWelcomePage() { const [testingCheckin, setTestingCheckin] = useState(false) const [testCheckinResult, setTestCheckinResult] = useState<{ ok: boolean; msg: string } | null>(null) - // Load settings + // Load settings + workstations useEffect(() => { if (!slug) return api.netup.getSettings(slug) @@ -66,9 +70,12 @@ export function TvWelcomePage() { setUsername(s.username) setLanguage(s.defaultLanguage) setEnabled(s.enabled) + setConnectionType((s.connectionType as 'direct' | 'agent') ?? 'direct') + setAgentWorkstationId(s.agentWorkstationId ?? null) setSettingsLoaded(true) }) .catch(() => setSettingsLoaded(true)) + api.workstations.list(slug).then(setWorkstations).catch(() => {}) }, [slug]) // Load room mappings when tab switches (also needed on 'log' for test button) @@ -102,6 +109,8 @@ export function TvWelcomePage() { password: password || undefined, default_language: language, enabled, tl_token: '', + connection_type: connectionType, + agent_workstation_id: connectionType === 'agent' ? agentWorkstationId : null, }) setPassword('') } finally { @@ -118,6 +127,8 @@ export function TvWelcomePage() { password: password || undefined, default_language: language, enabled, tl_token: '', + connection_type: connectionType, + agent_workstation_id: connectionType === 'agent' ? agentWorkstationId : null, }) const res = await api.netup.testConnection(slug) setTestResult({ ok: true, msg: res.message }) @@ -226,16 +237,71 @@ export function TvWelcomePage() {
+ {/* Connection type */} +
+ +
+ {([ + { id: 'direct', label: 'Прямое подключение', icon: Globe, hint: 'Сервер делает запросы напрямую к NetUP' }, + { id: 'agent', label: 'Через агент', icon: Wifi, hint: 'Запросы идут через Windows-агент в локальной сети' }, + ] as const).map(opt => ( + + ))} +
+
+ + {/* Agent workstation selector */} + {connectionType === 'agent' && ( +
+ + +

+ На этом рабочем месте должно быть добавлено устройство типа «NetUp IPTV» в разделе Оборудование +

+
+ )} + +
+ {/* Server URL */}
setServerUrl(e.target.value)} - placeholder="http://192.168.1.100:8880" + placeholder={connectionType === 'agent' ? 'http://172.16.0.12:8880' : 'http://192.168.1.100:8880'} className="input w-full font-mono text-sm" />

- Внешний адрес и порт, на который пробрасывается 172.16.0.12:80 + {connectionType === 'agent' + ? 'Локальный адрес NetUP в сети отеля (агент обращается к нему напрямую)' + : 'Внешний адрес и порт, на который пробрасывается 172.16.0.12:80'}