feat: workstations API + agent WebSocket — паркинг агентов и управление оборудованием

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 18:27:24 +03:00
parent be58b16365
commit 439be33c55
4 changed files with 432 additions and 0 deletions

View File

@@ -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 '{}'
);

133
backend/src/agent-ws.ts Normal file
View File

@@ -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<string, AgentSocket>()
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)
})
})
}

View File

@@ -33,6 +33,8 @@ import notificationsRoutes from './routes/notifications'
import scheduleRoutes from './routes/schedule' import scheduleRoutes from './routes/schedule'
import loyaltyRoutes from './routes/loyalty' import loyaltyRoutes from './routes/loyalty'
import chatRoutes from './routes/chat' import chatRoutes from './routes/chat'
import workstationRoutes from './routes/workstations'
import { setupAgentWsRoute } from './agent-ws'
import { startJobs } from './jobs' import { startJobs } from './jobs'
export async function buildApp() { export async function buildApp() {
@@ -111,6 +113,8 @@ export async function buildApp() {
await fastify.register(scheduleRoutes) await fastify.register(scheduleRoutes)
await fastify.register(loyaltyRoutes) await fastify.register(loyaltyRoutes)
await fastify.register(chatRoutes) await fastify.register(chatRoutes)
await fastify.register(workstationRoutes)
setupAgentWsRoute(fastify)
startJobs() startJobs()

View File

@@ -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<string | undefined> => {
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<SlugParam>('/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<SlugParam & { Body: { name: string } }>(
'/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<WsParam & { Body: { name?: string } }>(
'/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<WsParam>(
'/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<WsParam>(
'/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<WsParam>('/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<WsParam & { Body: {
type: string; name: string; connection: string
network_host?: string; network_port?: number; purpose?: string; config?: object
} }>(
'/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<DevParam & { Body: object }>(
'/api/hotels/:slug/workstations/:id/devices/:deviceId',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
const { deviceId } = req.params
const body = req.body as Record<string, unknown>
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<DevParam>(
'/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