feat: TTLock — add Учетная запись + Пароль fields for OAuth

Per Sciener docs: OAuth requires TTHotel account credentials (Учетная запись + Пароль)
separate from developer app client_id/client_secret.

- Migration 055: add ttlock_username, ttlock_password columns
- Backend: store and pass new fields to agent
- UI: add input fields matching TTHotel PMS integration page
- Agent: getAccessToken() uses ttlockUsername/ttlockPassword when provided

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 11:46:30 +03:00
parent a7b59c4881
commit 8c215f2dcd
4 changed files with 108 additions and 58 deletions

View File

@@ -0,0 +1,5 @@
-- TTHotel account credentials for OAuth (separate from developer app client_id/secret)
-- "Учетная запись" + "Пароль" from TTHotel → Settings → PMS Integration
ALTER TABLE hotel_ttlock_config
ADD COLUMN IF NOT EXISTS ttlock_username TEXT, -- "Учетная запись", e.g. h_1754382851163
ADD COLUMN IF NOT EXISTS ttlock_password TEXT; -- "Пароль" (stored as-is, md5'd before OAuth call)

View File

@@ -47,29 +47,32 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows } = await db.query( const { rows } = await db.query(
`SELECT is_enabled, client_id, card_sectors, api_server FROM hotel_ttlock_config WHERE hotel_id = $1`, `SELECT is_enabled, client_id, card_sectors, api_server, ttlock_username FROM hotel_ttlock_config WHERE hotel_id = $1`,
[hotelId], [hotelId],
) )
if (!rows[0]) { if (!rows[0]) {
return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euapi.ttlock.com' } return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euapi.ttlock.com', ttlockUsername: '' }
} }
const r = rows[0] const r = rows[0]
return { return {
isEnabled: r.is_enabled, isEnabled: r.is_enabled,
clientId: r.client_id, clientId: r.client_id,
cardSectors: r.card_sectors, cardSectors: r.card_sectors,
apiServer: r.api_server ?? 'https://euapi.ttlock.com', apiServer: r.api_server ?? 'https://euapi.ttlock.com',
ttlockUsername: r.ttlock_username ?? '',
} }
}) })
// ── PATCH /api/hotels/:slug/ttlock/config ─────────────────────────────────── // ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
fastify.patch<SlugParam & { fastify.patch<SlugParam & {
Body: { Body: {
is_enabled?: boolean is_enabled?: boolean
client_id?: string client_id?: string
client_secret?: string client_secret?: string
card_sectors?: string card_sectors?: string
api_server?: string api_server?: string
ttlock_username?: string
ttlock_password?: string
} }
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => { }>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params const { slug } = req.params
@@ -78,25 +81,33 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug) const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret, card_sectors: cardSectors, api_server: apiServer } = req.body const {
is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret,
card_sectors: cardSectors, api_server: apiServer,
ttlock_username: ttlockUsername, ttlock_password: ttlockPassword,
} = req.body
await db.query( await db.query(
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server) `INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server, ttlock_username, ttlock_password)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (hotel_id) DO UPDATE SET ON CONFLICT (hotel_id) DO UPDATE SET
is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled), is_enabled = COALESCE($2, hotel_ttlock_config.is_enabled),
client_id = COALESCE($3, hotel_ttlock_config.client_id), client_id = COALESCE($3, hotel_ttlock_config.client_id),
client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret), client_secret = COALESCE(NULLIF($4, ''), hotel_ttlock_config.client_secret),
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors), card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
api_server = COALESCE($6, hotel_ttlock_config.api_server), api_server = COALESCE($6, hotel_ttlock_config.api_server),
updated_at = NOW()`, ttlock_username = COALESCE(NULLIF($7, ''), hotel_ttlock_config.ttlock_username),
ttlock_password = COALESCE(NULLIF($8, ''), hotel_ttlock_config.ttlock_password),
updated_at = NOW()`,
[ [
hotelId, hotelId,
isEnabled ?? null, isEnabled ?? null,
clientId ?? null, clientId ?? null,
clientSecret ?? '', clientSecret ?? '',
cardSectors ?? null, cardSectors ?? null,
apiServer ?? null, apiServer ?? null,
ttlockUsername ?? '',
ttlockPassword ?? '',
], ],
) )
return { ok: true } return { ok: true }
@@ -173,7 +184,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { rows: cfgRows } = await db.query( const { rows: cfgRows } = await db.query(
'SELECT client_id, client_secret, api_server FROM hotel_ttlock_config WHERE hotel_id = $1', 'SELECT client_id, client_secret, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId], [hotelId],
) )
const cfg = cfgRows[0] const cfg = cfgRows[0]
@@ -184,10 +195,12 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
try { try {
const result = await sendCommandAndWait(workstationId, { const result = await sendCommandAndWait(workstationId, {
type: 'ttlock:test_api', type: 'ttlock:test_api',
clientId: cfg.client_id, clientId: cfg.client_id,
clientSecret: cfg.client_secret, clientSecret: cfg.client_secret,
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com', ttlockUsername: cfg.ttlock_username ?? '',
ttlockPassword: cfg.ttlock_password ?? '',
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
}, 15_000) as { ok?: boolean; lockCount?: number; error?: string } }, 15_000) as { ok?: boolean; lockCount?: number; error?: string }
if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' }) if (!result.ok) return reply.code(502).send({ error: result.error ?? 'Ошибка API' })
@@ -340,7 +353,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
// Конфигурация TTHotel (client_id, card_sectors) // Конфигурация TTHotel (client_id, card_sectors)
const { rows: cfgRows } = await db.query( const { rows: cfgRows } = await db.query(
'SELECT client_id, client_secret, card_sectors, is_enabled, api_server FROM hotel_ttlock_config WHERE hotel_id = $1', 'SELECT client_id, client_secret, card_sectors, is_enabled, api_server, ttlock_username, ttlock_password FROM hotel_ttlock_config WHERE hotel_id = $1',
[hotelId], [hotelId],
) )
const cfg = cfgRows[0] const cfg = cfgRows[0]
@@ -388,15 +401,17 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
let result: unknown let result: unknown
try { try {
result = await sendCommandAndWait(workstationId, { result = await sendCommandAndWait(workstationId, {
type: 'ttlock:write_card', type: 'ttlock:write_card',
lockMac: lock.lock_mac, lockMac: lock.lock_mac,
comPort: comPort, comPort: comPort,
cardSectors: cfg.card_sectors, cardSectors: cfg.card_sectors,
clientId: cfg.client_id, clientId: cfg.client_id,
clientSecret: cfg.client_secret, clientSecret: cfg.client_secret,
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com', ttlockUsername: cfg.ttlock_username ?? '',
startDate: checkIn, ttlockPassword: cfg.ttlock_password ?? '',
endDate: checkOut, apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
startDate: checkIn,
endDate: checkOut,
}, 30_000) }, 30_000)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Агент не ответил' const msg = err instanceof Error ? err.message : 'Агент не ответил'

View File

@@ -1094,18 +1094,21 @@ export interface Workstation {
} }
export interface TTLockConfig { export interface TTLockConfig {
isEnabled: boolean isEnabled: boolean
clientId: string clientId: string
cardSectors: string cardSectors: string
apiServer: string apiServer: string
ttlockUsername: string
} }
export interface TTLockConfigUpdate { export interface TTLockConfigUpdate {
isEnabled?: boolean isEnabled?: boolean
clientId?: string clientId?: string
clientSecret?: string clientSecret?: string
cardSectors?: string ttlock_username?: string
apiServer?: string ttlock_password?: string
cardSectors?: string
apiServer?: string
} }
export interface RoomLockMapping { export interface RoomLockMapping {

View File

@@ -139,11 +139,13 @@ export function TTLockPage() {
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
// Форма настроек // Форма настроек
const [isEnabled, setIsEnabled] = useState(false) const [isEnabled, setIsEnabled] = useState(false)
const [clientId, setClientId] = useState('') const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('') const [clientSecret, setClientSecret] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10') const [ttlockUsername, setTtlockUsername] = useState('')
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com') const [ttlockPassword, setTtlockPassword] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
// Per-workstation COM port state // Per-workstation COM port state
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({}) const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
@@ -242,6 +244,7 @@ export function TTLockPage() {
setConfig(cfg) setConfig(cfg)
setIsEnabled(cfg.isEnabled) setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId) setClientId(cfg.clientId)
setTtlockUsername(cfg.ttlockUsername || '')
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10') setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
setApiServer(cfg.apiServer || 'https://euapi.ttlock.com') setApiServer(cfg.apiServer || 'https://euapi.ttlock.com')
setMappings(maps) setMappings(maps)
@@ -263,13 +266,16 @@ export function TTLockPage() {
try { try {
await api.ttlock.updateConfig(slug, { await api.ttlock.updateConfig(slug, {
isEnabled, isEnabled,
clientId: clientId.trim(), clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined, clientSecret: clientSecret.trim() || undefined,
cardSectors: cardSectors.trim(), ttlock_username: ttlockUsername.trim() || undefined,
apiServer: apiServer, ttlock_password: ttlockPassword.trim() || undefined,
cardSectors: cardSectors.trim(),
apiServer: apiServer,
}) })
showSuccess('Настройки сохранены') showSuccess('Настройки сохранены')
setClientSecret('') setClientSecret('')
setTtlockPassword('')
await load() await load()
} catch { } catch {
setError('Ошибка сохранения') setError('Ошибка сохранения')
@@ -410,6 +416,27 @@ export function TTLockPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="form-label">Учетная запись <span className="font-normal text-slate-400">(поле «Учетная запись» в TTHotel)</span></label>
<input
type="text"
value={ttlockUsername}
onChange={e => setTtlockUsername(e.target.value)}
placeholder="h_1754382851163"
className="input w-full font-mono text-sm"
/>
</div>
<div>
<label className="form-label">Пароль <span className="font-normal text-slate-400">(поле «Пароль» в TTHotel)</span></label>
<PasswordInput
value={ttlockPassword}
onChange={setTtlockPassword}
placeholder={ttlockUsername && !ttlockPassword ? '••••••• (не изменится)' : 'Пароль от учетной записи TTHotel'}
/>
</div>
</div>
{/* Сервер TTLock API */} {/* Сервер TTLock API */}
<div> <div>
<label className="form-label">Сервер TTLock API</label> <label className="form-label">Сервер TTLock API</label>