feat: TTLock settings redesign — correct TTHotel DLL architecture

- Settings page rebuilt with 3-step flow:
  Step 1: client_id / client_secret (from TTHotel → Settings → Integrations)
  Step 2: workstation + COM port + card sectors
  Step 3: room → lock MAC address mapping (from lock.ttlock.com)
- Agent command ttlock:write_card now uses lockMac, comPort, cardSectors
  instead of cloud-only lockId approach
- New agent command ttlock:ping_encoder for encoder connectivity check
- Migration 049_ttlock_v2.sql: lock_mac TEXT replaces lock_id BIGINT,
  card_sectors + com_port added, ttlock_username/password removed
- API types updated: TTLockConfig, RoomLockMapping, CardIssuance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:29:30 +03:00
parent be92d026e8
commit 249492cf3a
4 changed files with 391 additions and 456 deletions

View File

@@ -1,87 +1,41 @@
/**
* TTLock integration routes
* TTLock / TTHotel integration
*
* GET /api/hotels/:slug/ttlock/config — get config
* PATCH /api/hotels/:slug/ttlock/config — save config
* POST /api/hotels/:slug/ttlock/token — obtain/refresh TTLock access token
* GET /api/hotels/:slug/ttlock/locks — list locks from TTLock API
* GET /api/hotels/:slug/ttlock/room-locks — list room→lock mappings
* POST /api/hotels/:slug/ttlock/room-locks — map room to lock
* DELETE /api/hotels/:slug/ttlock/room-locks/:roomId — unmap room
* POST /api/hotels/:slug/bookings/:bookingId/issue-card — issue card via agent
* GET /api/hotels/:slug/bookings/:bookingId/cards — card issuance history
* Архитектура:
* PMS backend → AgentWS → Windows Agent → Card Encoder DLL → USB Encoder (COM-порт) → Mifare-карта → Замок
*
* Credentials: client_id + client_secret из TTHotel → Настройки → Интеграции
* Lock id: MAC-адрес замка из lock.ttlock.com (без двоеточий, напр. "42A6BBF5ECE5")
* COM-порт: из Диспетчера устройств → Порты
* Секторы: из TTHotel (напр. "1,2,3,4,5,6,7,8,9,10")
*
* Routes:
* GET /api/hotels/:slug/ttlock/config
* PATCH /api/hotels/:slug/ttlock/config
* POST /api/hotels/:slug/ttlock/test — проверка подключения агента и COM-порта
* GET /api/hotels/:slug/ttlock/room-locks — список привязок номер → замок
* POST /api/hotels/:slug/ttlock/room-locks — привязать номер к замку
* DELETE /api/hotels/:slug/ttlock/room-locks/:roomId
* POST /api/hotels/:slug/bookings/:bookingId/issue-card
* GET /api/hotels/:slug/bookings/:bookingId/cards
*/
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { sendCommandAndWait } from '../agent-ws'
import crypto from 'crypto'
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: string } }
type SlugParam = { Params: { slug: string } }
type RoomParam = { Params: { slug: string; roomId: string } }
type BookingParam = { Params: { slug: string; bookingId: string } }
// TTLock Open Platform base URL — eu region
const TTLOCK_API = 'https://euopen.ttlock.com'
async function getHotelId(slug: string): Promise<string | undefined> {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id
}
/** MD5 hash for TTLock password field (their API requirement) */
function md5(text: string): string {
return crypto.createHash('md5').update(text).digest('hex')
}
/** Fetch TTLock access token using Resource Owner Password Grant */
async function fetchTTLockToken(clientId: string, clientSecret: string, username: string, password: string) {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'password',
username,
password: md5(password),
})
const res = await fetch(`${TTLOCK_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
})
if (!res.ok) throw new Error(`TTLock auth failed: ${res.status}`)
return res.json() as Promise<{ access_token: string; refresh_token: string; expires_in: number }>
}
/** Ensure we have a valid access token, refreshing if needed */
async function ensureToken(hotelId: string): Promise<string> {
const { rows } = await db.query(
'SELECT * FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId],
)
const cfg = rows[0]
if (!cfg) throw new Error('TTLock не настроен')
if (!cfg.client_id || !cfg.ttlock_username) throw new Error('TTLock credentials не заполнены')
// Check if current token still valid (>60s buffer)
if (cfg.access_token && cfg.token_expires_at) {
const expiresAt = new Date(cfg.token_expires_at).getTime()
if (Date.now() < expiresAt - 60_000) return cfg.access_token
}
// Obtain fresh token
const token = await fetchTTLockToken(cfg.client_id, cfg.client_secret, cfg.ttlock_username, cfg.ttlock_password)
const expiresAt = new Date(Date.now() + token.expires_in * 1000)
await db.query(
`UPDATE hotel_ttlock_config
SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW()
WHERE hotel_id = $4`,
[token.access_token, token.refresh_token, expiresAt, hotelId],
)
return token.access_token
}
const canManage = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || role === 'hotel_admin' || userSlug === slug
const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const canManage = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || role === 'hotel_admin' || userSlug === slug
// ── GET /api/hotels/:slug/ttlock/config ─────────────────────────────────────
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
@@ -92,34 +46,31 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT id, is_enabled, client_id, ttlock_username, workstation_id,
access_token IS NOT NULL AS has_token, token_expires_at
`SELECT is_enabled, client_id, card_sectors, com_port, workstation_id
FROM hotel_ttlock_config WHERE hotel_id = $1`,
[hotelId],
)
if (!rows[0]) {
// Return defaults
return { isEnabled: false, clientId: '', ttlockUsername: '', workstationId: null, hasToken: false }
return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', comPort: '', workstationId: null }
}
const r = rows[0]
return {
isEnabled: r.is_enabled,
clientId: r.client_id,
ttlockUsername: r.ttlock_username,
cardSectors: r.card_sectors,
comPort: r.com_port,
workstationId: r.workstation_id,
hasToken: r.has_token,
tokenExpiresAt: r.token_expires_at,
}
})
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
fastify.patch<SlugParam & {
Body: {
isEnabled?: boolean
clientId?: string
clientSecret?: string
ttlockUsername?: string
ttlockPassword?: string
isEnabled?: boolean
clientId?: string
clientSecret?: string
cardSectors?: string
comPort?: string
workstationId?: string | null
}
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
@@ -129,88 +80,65 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { isEnabled, clientId, clientSecret, ttlockUsername, ttlockPassword, workstationId } = req.body
const { isEnabled, clientId, clientSecret, cardSectors, comPort, workstationId } = req.body
await db.query(
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, ttlock_username, ttlock_password, workstation_id)
`INSERT INTO hotel_ttlock_config
(hotel_id, is_enabled, client_id, client_secret, card_sectors, com_port, workstation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hotel_id) DO UPDATE SET
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
client_id = COALESCE($3, hotel_ttlock_config.client_id),
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
ttlock_username = COALESCE($5, hotel_ttlock_config.ttlock_username),
ttlock_password = COALESCE(NULLIF($6, ''), hotel_ttlock_config.ttlock_password),
workstation_id = $7,
access_token = NULL,
token_expires_at = NULL,
updated_at = NOW()`,
[hotelId, isEnabled ?? true, clientId ?? '', clientSecret ?? '', ttlockUsername ?? '', ttlockPassword ?? '', workstationId ?? null],
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
client_id = COALESCE($3, hotel_ttlock_config.client_id),
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
com_port = COALESCE($6, hotel_ttlock_config.com_port),
workstation_id = $7,
updated_at = NOW()`,
[
hotelId,
isEnabled ?? null,
clientId ?? null,
clientSecret ?? '',
cardSectors ?? null,
comPort ?? null,
workstationId ?? null,
],
)
return { ok: true }
})
// ── POST /api/hotels/:slug/ttlock/token ─────────────────────────────────────
// Force re-auth with TTLock and store fresh token
fastify.post<SlugParam>('/api/hotels/:slug/ttlock/token', { onRequest: [fastify.authenticate] }, async (req, reply) => {
// ── POST /api/hotels/:slug/ttlock/test ──────────────────────────────────────
// Проверяем: агент онлайн, энкодер отвечает на COM-порту
fastify.post<SlugParam>('/api/hotels/:slug/ttlock/test', { 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' })
try {
const { rows } = await db.query('SELECT * FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId])
const cfg = rows[0]
if (!cfg?.client_id || !cfg.ttlock_username) return reply.code(400).send({ error: 'Заполните credentials' })
const { rows } = await db.query(
'SELECT workstation_id, com_port FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId],
)
const cfg = rows[0]
if (!cfg?.workstation_id) return reply.code(400).send({ error: 'Рабочее место не выбрано' })
if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт не указан' })
const token = await fetchTTLockToken(cfg.client_id, cfg.client_secret, cfg.ttlock_username, cfg.ttlock_password)
const expiresAt = new Date(Date.now() + token.expires_in * 1000)
await db.query(
'UPDATE hotel_ttlock_config SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW() WHERE hotel_id = $4',
[token.access_token, token.refresh_token, expiresAt, hotelId],
)
return { ok: true, expiresAt }
try {
const result = await sendCommandAndWait(cfg.workstation_id, {
type: 'ttlock:ping_encoder',
comPort: cfg.com_port,
}, 10_000) as { ok?: boolean; error?: string }
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Энкодер не ответил' })
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
return reply.code(400).send({ error: msg })
const msg = err instanceof Error ? err.message : 'Агент не ответил'
return reply.code(502).send({ error: msg })
}
})
// ── GET /api/hotels/:slug/ttlock/locks ──────────────────────────────────────
// List locks from TTLock API (requires valid token)
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/locks', { 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' })
try {
const { rows } = await db.query('SELECT client_id FROM hotel_ttlock_config WHERE hotel_id = $1', [hotelId])
const clientId = rows[0]?.client_id
const accessToken = await ensureToken(hotelId)
const params = new URLSearchParams({
clientId: clientId,
accessToken,
pageNo: '1',
pageSize: '100',
date: Date.now().toString(),
})
const res = await fetch(`${TTLOCK_API}/v3/lock/list?${params}`)
if (!res.ok) return reply.code(502).send({ error: 'TTLock API error' })
const data = await res.json() as { list?: { lockId: number; lockName: string; lockAlias: string }[] }
return (data.list ?? []).map(l => ({
lockId: l.lockId,
name: l.lockAlias || l.lockName,
}))
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка'
return reply.code(400).send({ error: msg })
}
})
// ── GET /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
// ── GET /api/hotels/:slug/ttlock/room-locks ──────────────────────────────────
fastify.get<SlugParam>('/api/hotels/:slug/ttlock/room-locks', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
@@ -219,7 +147,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT m.room_id, m.lock_id, m.lock_name, r.name AS room_name, r.number AS room_number
`SELECT m.room_id, m.lock_mac, m.lock_name,
r.name AS room_name, r.number AS room_number
FROM room_lock_mappings m
JOIN rooms r ON r.id = m.room_id
WHERE m.hotel_id = $1
@@ -230,13 +159,13 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
roomId: r.room_id,
roomName: r.room_name,
roomNumber: r.room_number,
lockId: Number(r.lock_id),
lockMac: r.lock_mac,
lockName: r.lock_name,
}))
})
// ── POST /api/hotels/:slug/ttlock/room-locks ─────────────────────────────────
fastify.post<SlugParam & { Body: { roomId: string; lockId: number; lockName?: string } }>(
fastify.post<SlugParam & { Body: { roomId: string; lockMac: string; lockName?: string } }>(
'/api/hotels/:slug/ttlock/room-locks',
{ onRequest: [fastify.authenticate] },
async (req, reply) => {
@@ -246,18 +175,24 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { roomId, lockId, lockName } = req.body
const { roomId, lockMac, lockName } = req.body
// Нормализуем MAC: убираем двоеточия, приводим к верхнему регистру
const mac = lockMac.replace(/:/g, '').toUpperCase()
if (!/^[0-9A-F]{12}$/.test(mac)) {
return reply.code(400).send({ error: 'MAC-адрес должен быть 12 HEX-символов (напр. 42A6BBF5ECE5)' })
}
await db.query(
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_id, lock_name)
`INSERT INTO room_lock_mappings (hotel_id, room_id, lock_mac, lock_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_id = $3, lock_name = $4`,
[hotelId, roomId, lockId, lockName ?? null],
ON CONFLICT (hotel_id, room_id) DO UPDATE SET lock_mac = $3, lock_name = $4`,
[hotelId, roomId, mac, lockName ?? null],
)
return { ok: true }
},
)
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ──────────────────────
// ── DELETE /api/hotels/:slug/ttlock/room-locks/:roomId ──────────────────────
fastify.delete<RoomParam>('/api/hotels/:slug/ttlock/room-locks/:roomId', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug, roomId } = req.params
if (!canManage(req.user.hotelSlug, req.user.role, slug))
@@ -269,7 +204,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
return { ok: true }
})
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ───────────────────
// ── POST /api/hotels/:slug/bookings/:bookingId/issue-card ───────────────────
fastify.post<BookingParam>(
'/api/hotels/:slug/bookings/:bookingId/issue-card',
{ onRequest: [fastify.authenticate] },
@@ -280,9 +215,9 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
// Get booking + room info
// Бронирование + номер комнаты
const { rows: bookingRows } = await db.query(
`SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name
`SELECT b.id, b.room_id, b.check_in, b.check_out, r.name AS room_name, r.number AS room_number
FROM bookings b JOIN rooms r ON r.id = b.room_id
WHERE b.id = $1 AND b.hotel_id = $2`,
[bookingId, hotelId],
@@ -290,43 +225,38 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const booking = bookingRows[0]
if (!booking) return reply.code(404).send({ error: 'Бронирование не найдено' })
// Get lock mapping for this room
// MAC замка для этого номера
const { rows: lockRows } = await db.query(
'SELECT lock_id, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
'SELECT lock_mac, lock_name FROM room_lock_mappings WHERE hotel_id = $1 AND room_id = $2',
[hotelId, booking.room_id],
)
const lockMapping = lockRows[0]
if (!lockMapping) return reply.code(400).send({ error: `Для номера "${booking.room_name}" не назначен замок` })
const lock = lockRows[0]
if (!lock) return reply.code(400).send({ error: `Номер "${booking.room_name}" не привязан к замку TTLock` })
// Get TTLock config + workstation
// Конфигурация TTHotel (client_id, com_port, sectors, workstation)
const { rows: cfgRows } = await db.query(
'SELECT client_id, workstation_id, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1',
'SELECT client_id, client_secret, card_sectors, com_port, workstation_id, is_enabled FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId],
)
const cfg = cfgRows[0]
if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
if (!cfg.workstation_id) return reply.code(400).send({ error: 'Не выбрано рабочее место с картридером' })
if (!cfg?.is_enabled) return reply.code(400).send({ error: 'TTLock модуль не активирован' })
if (!cfg.workstation_id) return reply.code(400).send({ error: 'Рабочее место с энкодером не выбрано' })
if (!cfg.com_port) return reply.code(400).send({ error: 'COM-порт энкодера не указан' })
if (!cfg.client_id) return reply.code(400).send({ error: 'client_id не заполнен' })
// Get fresh access token
let accessToken: string
try {
accessToken = await ensureToken(hotelId)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Ошибка авторизации'
return reply.code(400).send({ error: msg })
}
// Send command to agent
const checkIn = new Date(booking.check_in).getTime()
const checkOut = new Date(booking.check_out).getTime()
// Отправляем команду агенту — он делает всё остальное через DLL
let result: unknown
try {
result = await sendCommandAndWait(cfg.workstation_id, {
type: 'ttlock:write_card',
lockId: Number(lockMapping.lock_id),
lockMac: lock.lock_mac,
comPort: cfg.com_port,
cardSectors: cfg.card_sectors,
clientId: cfg.client_id,
accessToken,
clientSecret: cfg.client_secret,
startDate: checkIn,
endDate: checkOut,
}, 30_000)
@@ -335,34 +265,29 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
return reply.code(502).send({ error: msg })
}
const agentResult = result as { ok?: boolean; error?: string; cardNumber?: string; ttlockCardId?: number }
if (!agentResult.ok) {
return reply.code(502).send({ error: agentResult.error ?? 'Ошибка записи карты' })
}
const r = result as { ok?: boolean; error?: string; cardNumber?: string }
if (!r.ok) return reply.code(502).send({ error: r.error ?? 'Ошибка записи карты' })
// Log issuance
// Записываем в лог
const { rows: issuanceRows } = await db.query(
`INSERT INTO card_issuances
(hotel_id, booking_id, room_id, lock_id, ttlock_card_id, issued_by, check_in, check_out)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
(hotel_id, booking_id, room_id, lock_mac, issued_by, check_in, check_out)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, issued_at`,
[
hotelId, bookingId, booking.room_id,
Number(lockMapping.lock_id),
agentResult.ttlockCardId ?? null,
req.user.sub,
booking.check_in, booking.check_out,
],
[hotelId, bookingId, booking.room_id, lock.lock_mac, req.user.sub, booking.check_in, booking.check_out],
)
return {
ok: true,
issuanceId: issuanceRows[0].id,
issuedAt: issuanceRows[0].issued_at,
lockName: lockMapping.lock_name,
roomName: booking.room_name,
roomNumber: booking.room_number,
lockMac: lock.lock_mac,
lockName: lock.lock_name,
checkIn: booking.check_in,
checkOut: booking.check_out,
cardNumber: r.cardNumber,
}
},
)
@@ -376,7 +301,8 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query(
`SELECT c.*, u.name AS issued_by_name
`SELECT c.id, c.lock_mac, c.issued_at, c.check_in, c.check_out,
c.is_revoked, c.revoked_at, u.name AS issued_by_name
FROM card_issuances c
LEFT JOIN users u ON u.id = c.issued_by
WHERE c.hotel_id = $1 AND c.booking_id = $2
@@ -384,14 +310,14 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
[hotelId, bookingId],
)
return rows.map(r => ({
id: r.id,
lockId: Number(r.lock_id),
issuedAt: r.issued_at,
issuedBy: r.issued_by_name,
checkIn: r.check_in,
checkOut: r.check_out,
isRevoked: r.is_revoked,
revokedAt: r.revoked_at,
id: r.id,
lockMac: r.lock_mac,
issuedAt: r.issued_at,
issuedBy: r.issued_by_name,
checkIn: r.check_in,
checkOut: r.check_out,
isRevoked: r.is_revoked,
revokedAt: r.revoked_at,
}))
})
}