feat: NetUP via agent — proxy HTTP requests through Windows workstation

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 13:53:55 +03:00
parent c410ef8ba4
commit 2caf717131
4 changed files with 218 additions and 59 deletions

View File

@@ -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;

View File

@@ -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<SlugParam>(
'/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<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string } }>(
fastify.patch<SlugParam & { Body: { server_url?: string; username?: string; password?: string; default_language?: string; enabled?: boolean; tl_token?: string; connection_type?: string; agent_workstation_id?: string | null } }>(
'/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 : 'Ошибка соединения'