From 439be33c555726d01e16435bd2d78bd6e9c66ec3 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Thu, 26 Mar 2026 18:27:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20workstations=20API=20+=20agent=20WebSoc?= =?UTF-8?q?ket=20=E2=80=94=20=D0=BF=D0=B0=D1=80=D0=BA=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D1=83=D0=B4=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 041: таблицы workstations, agent_pair_codes, workstation_devices - routes/workstations: CRUD рабочих мест, генерация 6-значного кода, CRUD устройств - POST /api/agent/pair: сопряжение агента по коду → JWT токен - agent-ws.ts: WebSocket /ws/agent для постоянного соединения агентов, heartbeat 30s, онлайн/оффлайн статус в БД Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/041_workstations.sql | 33 +++ backend/src/agent-ws.ts | 133 ++++++++++++ backend/src/app.ts | 4 + backend/src/routes/workstations.ts | 262 ++++++++++++++++++++++++ 4 files changed, 432 insertions(+) create mode 100644 backend/migrations/041_workstations.sql create mode 100644 backend/src/agent-ws.ts create mode 100644 backend/src/routes/workstations.ts diff --git a/backend/migrations/041_workstations.sql b/backend/migrations/041_workstations.sql new file mode 100644 index 0000000..be4303e --- /dev/null +++ b/backend/migrations/041_workstations.sql @@ -0,0 +1,33 @@ +-- Рабочие места (агенты Windows) +CREATE TABLE IF NOT EXISTS workstations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + agent_id VARCHAR(64) UNIQUE, -- UUID генерируемый агентом + name VARCHAR(100) NOT NULL, -- «Ресепшн 1», «Бар» + hostname VARCHAR(255), -- имя Windows-компьютера + ip_address INET, + is_online BOOLEAN NOT NULL DEFAULT false, + last_seen TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Коды сопряжения (6 цифр, TTL 10 минут) +CREATE TABLE IF NOT EXISTS agent_pair_codes ( + code CHAR(6) PRIMARY KEY, + workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT false +); + +-- Устройства привязанные к рабочему месту +CREATE TABLE IF NOT EXISTS workstation_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workstation_id UUID NOT NULL REFERENCES workstations(id) ON DELETE CASCADE, + type VARCHAR(20) NOT NULL CHECK (type IN ('kkt', 'printer')), + name VARCHAR(100) NOT NULL, + connection VARCHAR(20) NOT NULL CHECK (connection IN ('usb', 'network')), + network_host VARCHAR(255), + network_port INTEGER, + purpose VARCHAR(30) CHECK (purpose IN ('fiscal', 'kitchen', 'bar', 'receipt', 'other')), + config JSONB NOT NULL DEFAULT '{}' +); diff --git a/backend/src/agent-ws.ts b/backend/src/agent-ws.ts new file mode 100644 index 0000000..0b08774 --- /dev/null +++ b/backend/src/agent-ws.ts @@ -0,0 +1,133 @@ +/** + * WebSocket сервер для агентов + * Агенты подключаются по wss://api.hotelsync.ru/ws/agent + * и остаются подключёнными для получения команд + */ +import { FastifyInstance } from 'fastify' +import { WebSocket } from 'ws' +import { db } from './db' + +interface AgentSocket extends WebSocket { + agentId?: string + workstationId?: string + hotelSlug?: string + isAlive?: boolean +} + +// Карта: workstation_id → сокет +const agentSockets = new Map() + +export function getAgentSocket(workstationId: string): AgentSocket | undefined { + return agentSockets.get(workstationId) +} + +export async function sendCommand( + workstationId: string, + command: object, +): Promise<{ ok: boolean; error?: string }> { + const socket = agentSockets.get(workstationId) + if (!socket || socket.readyState !== WebSocket.OPEN) { + return { ok: false, error: 'Агент не подключён' } + } + socket.send(JSON.stringify(command)) + return { ok: true } +} + +export function registerAgentWs(fastify: FastifyInstance) { + // @ts-ignore — fastify.websocketServer добавляется плагином @fastify/websocket + const wss = fastify.websocketServer + + // Heartbeat каждые 30 секунд + const heartbeat = setInterval(() => { + agentSockets.forEach((socket, wsId) => { + if (!socket.isAlive) { + console.log(`[agent-ws] Disconnect (timeout): ${wsId}`) + socket.terminate() + agentSockets.delete(wsId) + markOffline(wsId) + return + } + socket.isAlive = false + socket.ping() + }) + }, 30000) + + wss.on('close', () => clearInterval(heartbeat)) +} + +async function markOffline(workstationId: string) { + await db.query( + 'UPDATE workstations SET is_online = false WHERE id = $1', + [workstationId], + ).catch(() => {}) +} + +async function markOnline(workstationId: string, ip?: string) { + await db.query( + `UPDATE workstations SET is_online = true, last_seen = NOW(), ip_address = $2 WHERE id = $1`, + [workstationId, ip ?? null], + ).catch(() => {}) +} + +// Регистрируем route для WebSocket агентов +export function setupAgentWsRoute(fastify: FastifyInstance) { + fastify.get('/ws/agent', { websocket: true }, async (socket: AgentSocket, req) => { + // Аутентификация по токену из заголовка + try { + const authHeader = req.headers.authorization ?? '' + const token = authHeader.replace('Bearer ', '') + if (!token) { socket.close(1008, 'Unauthorized'); return } + + const payload = fastify.jwt.verify(token) as { + sub: string; role: string; workstation_id: string; hotel_slug: string + } + + if (payload.role !== 'agent') { socket.close(1008, 'Forbidden'); return } + + socket.agentId = payload.sub + socket.workstationId = payload.workstation_id + socket.hotelSlug = payload.hotel_slug + socket.isAlive = true + + } catch { + socket.close(1008, 'Invalid token') + return + } + + const wsId = socket.workstationId! + console.log(`[agent-ws] Connected: ${wsId}`) + + // Если уже было соединение — закрываем старое + const existing = agentSockets.get(wsId) + if (existing) existing.terminate() + + agentSockets.set(wsId, socket) + await markOnline(wsId, req.ip) + + socket.on('pong', () => { socket.isAlive = true }) + + socket.on('message', (raw) => { + try { + const msg = JSON.parse(raw.toString()) + // Обрабатываем ответы агента (результаты команд) + if (msg.type === 'result') { + fastify.log.info({ workstation: wsId, result: msg }, 'Agent command result') + // TODO: можно эмитить в EventEmitter для ожидающих промисов + } + if (msg.type === 'hello') { + console.log(`[agent-ws] Hello from ${msg.agent_id} (${wsId})`) + } + } catch {} + }) + + socket.on('close', () => { + console.log(`[agent-ws] Disconnected: ${wsId}`) + agentSockets.delete(wsId) + markOffline(wsId) + }) + + socket.on('error', (err) => { + console.error(`[agent-ws] Error ${wsId}:`, err.message) + }) + }) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 96bf411..286a84e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -33,6 +33,8 @@ import notificationsRoutes from './routes/notifications' import scheduleRoutes from './routes/schedule' import loyaltyRoutes from './routes/loyalty' import chatRoutes from './routes/chat' +import workstationRoutes from './routes/workstations' +import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' export async function buildApp() { @@ -111,6 +113,8 @@ export async function buildApp() { await fastify.register(scheduleRoutes) await fastify.register(loyaltyRoutes) await fastify.register(chatRoutes) + await fastify.register(workstationRoutes) + setupAgentWsRoute(fastify) startJobs() diff --git a/backend/src/routes/workstations.ts b/backend/src/routes/workstations.ts new file mode 100644 index 0000000..6dd6fdd --- /dev/null +++ b/backend/src/routes/workstations.ts @@ -0,0 +1,262 @@ +/** + * Рабочие места и сопряжение агентов + * Routes: + * GET /api/hotels/:slug/workstations + * POST /api/hotels/:slug/workstations + * PATCH /api/hotels/:slug/workstations/:id + * DELETE /api/hotels/:slug/workstations/:id + * POST /api/hotels/:slug/workstations/:id/pair-code + * GET /api/hotels/:slug/workstations/:id/devices + * POST /api/hotels/:slug/workstations/:id/devices + * PATCH /api/hotels/:slug/workstations/:id/devices/:deviceId + * DELETE /api/hotels/:slug/workstations/:id/devices/:deviceId + * + * POST /api/agent/pair (публичный — без auth) + */ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' +import crypto from 'crypto' + +type SlugParam = { Params: { slug: string } } +type WsParam = { Params: { slug: string; id: string } } +type DevParam = { Params: { slug: string; id: string; deviceId: string } } + +const workstationRoutes: FastifyPluginAsync = async (fastify) => { + const getHotelId = async (slug: string): Promise => { + const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug]) + return rows[0]?.id + } + + const canManage = (userSlug: string | null, role: string, slug: string) => + role === 'super_admin' || role === 'hotel_admin' || userSlug === slug + + // ── GET /api/hotels/:slug/workstations ────────────────────────────────────── + fastify.get('/api/hotels/:slug/workstations', { onRequest: [fastify.authenticate] }, async (req, reply) => { + const { slug } = req.params + if (!canManage(req.user.hotelSlug, req.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { rows } = await db.query( + `SELECT w.*, + json_agg(d ORDER BY d.type, d.name) FILTER (WHERE d.id IS NOT NULL) AS devices + FROM workstations w + LEFT JOIN workstation_devices d ON d.workstation_id = w.id + WHERE w.hotel_id = $1 + GROUP BY w.id + ORDER BY w.created_at`, + [hotelId], + ) + return rows + }) + + // ── POST /api/hotels/:slug/workstations ───────────────────────────────────── + fastify.post( + '/api/hotels/:slug/workstations', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + const { slug } = req.params + if (!canManage(req.user.hotelSlug, req.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { name } = req.body + if (!name?.trim()) return reply.code(400).send({ error: 'name required' }) + + const { rows } = await db.query( + `INSERT INTO workstations (hotel_id, name) VALUES ($1, $2) RETURNING *`, + [hotelId, name.trim()], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/workstations/:id ──────────────────────────────── + fastify.patch( + '/api/hotels/:slug/workstations/:id', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + const { slug, id } = req.params + if (!canManage(req.user.hotelSlug, req.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + + const { name } = req.body + if (!name?.trim()) return reply.code(400).send({ error: 'name required' }) + + const { rows } = await db.query( + `UPDATE workstations SET name = $1 WHERE id = $2 RETURNING *`, + [name.trim(), id], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/workstations/:id ─────────────────────────────── + fastify.delete( + '/api/hotels/:slug/workstations/:id', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + const { slug, id } = req.params + if (!canManage(req.user.hotelSlug, req.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + + await db.query('DELETE FROM workstations WHERE id = $1', [id]) + return reply.code(204).send() + }, + ) + + // ── POST /api/hotels/:slug/workstations/:id/pair-code ─────────────────────── + fastify.post( + '/api/hotels/:slug/workstations/:id/pair-code', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + const { slug, id } = req.params + if (!canManage(req.user.hotelSlug, req.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + + // Удаляем старые неиспользованные коды для этого рабочего места + await db.query( + `DELETE FROM agent_pair_codes WHERE workstation_id = $1`, + [id], + ) + + // Генерируем 6-значный код + const code = String(crypto.randomInt(100000, 999999)) + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) // 10 минут + + await db.query( + `INSERT INTO agent_pair_codes (code, workstation_id, expires_at) VALUES ($1, $2, $3)`, + [code, id, expiresAt], + ) + + return { code, expires_at: expiresAt } + }, + ) + + // ── POST /api/agent/pair (без auth — вызывается агентом) ──────────────────── + fastify.post<{ Body: { code: string; agent_id: string; hostname?: string } }>( + '/api/agent/pair', + async (req, reply) => { + const { code, agent_id, hostname } = req.body + if (!code || !agent_id) return reply.code(400).send({ error: 'code and agent_id required' }) + + const { rows } = await db.query( + `SELECT pc.*, w.hotel_id, w.name AS workstation_name, h.slug + FROM agent_pair_codes pc + JOIN workstations w ON w.id = pc.workstation_id + JOIN hotels h ON h.id = w.hotel_id + WHERE pc.code = $1 + AND pc.used = false + AND pc.expires_at > NOW()`, + [code], + ) + + if (!rows[0]) { + return reply.code(400).send({ error: 'Код недействителен или истёк' }) + } + + const row = rows[0] + + // Помечаем код как использованный + await db.query('UPDATE agent_pair_codes SET used = true WHERE code = $1', [code]) + + // Привязываем agent_id к рабочему месту + await db.query( + `UPDATE workstations SET agent_id = $1, hostname = $2, last_seen = NOW(), is_online = false + WHERE id = $3`, + [agent_id, hostname ?? null, row.workstation_id], + ) + + // Выдаём токен агенту (подписываем как обычный JWT с role=agent) + const token = fastify.jwt.sign( + { + sub: agent_id, + role: 'agent', + workstation_id: row.workstation_id, + hotel_slug: row.slug, + }, + { expiresIn: '365d' }, + ) + + return { + token, + workstation_id: row.workstation_id, + workstation_name: row.workstation_name, + hotel_slug: row.slug, + } + }, + ) + + // ── GET /api/hotels/:slug/workstations/:id/devices ────────────────────────── + fastify.get('/api/hotels/:slug/workstations/:id/devices', { onRequest: [fastify.authenticate] }, async (req, reply) => { + const { slug, id } = req.params + if (!canManage(req.user.hotelSlug, req.user.role, slug)) + return reply.code(403).send({ error: 'Forbidden' }) + + const { rows } = await db.query( + 'SELECT * FROM workstation_devices WHERE workstation_id = $1 ORDER BY type, name', + [id], + ) + return rows + }) + + // ── POST /api/hotels/:slug/workstations/:id/devices ───────────────────────── + fastify.post( + '/api/hotels/:slug/workstations/:id/devices', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + const { id } = req.params + const { type, name, connection, network_host, network_port, purpose, config } = req.body + const { rows } = await db.query( + `INSERT INTO workstation_devices (workstation_id, type, name, connection, network_host, network_port, purpose, config) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [id, type, name, connection, network_host ?? null, network_port ?? null, purpose ?? 'other', config ?? {}], + ) + return reply.code(201).send(rows[0]) + }, + ) + + // ── PATCH /api/hotels/:slug/workstations/:id/devices/:deviceId ────────────── + fastify.patch( + '/api/hotels/:slug/workstations/:id/devices/:deviceId', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + const { deviceId } = req.params + const body = req.body as Record + const fields = ['name', 'connection', 'network_host', 'network_port', 'purpose', 'config'] + const sets: string[] = [] + const vals: unknown[] = [] + fields.forEach(f => { + if (f in body) { sets.push(`${f} = $${vals.length + 1}`); vals.push(body[f]) } + }) + if (!sets.length) return reply.code(400).send({ error: 'Nothing to update' }) + vals.push(deviceId) + const { rows } = await db.query( + `UPDATE workstation_devices SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`, + vals, + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }, + ) + + // ── DELETE /api/hotels/:slug/workstations/:id/devices/:deviceId ───────────── + fastify.delete( + '/api/hotels/:slug/workstations/:id/devices/:deviceId', + { onRequest: [fastify.authenticate] }, + async (req, reply) => { + await db.query('DELETE FROM workstation_devices WHERE id = $1', [req.params.deviceId]) + return reply.code(204).send() + }, + ) +} + +export default workstationRoutes