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' })
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],
)
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]
return {
isEnabled: r.is_enabled,
clientId: r.client_id,
cardSectors: r.card_sectors,
apiServer: r.api_server ?? 'https://euapi.ttlock.com',
isEnabled: r.is_enabled,
clientId: r.client_id,
cardSectors: r.card_sectors,
apiServer: r.api_server ?? 'https://euapi.ttlock.com',
ttlockUsername: r.ttlock_username ?? '',
}
})
// ── PATCH /api/hotels/:slug/ttlock/config ───────────────────────────────────
fastify.patch<SlugParam & {
Body: {
is_enabled?: boolean
client_id?: string
client_secret?: string
card_sectors?: string
api_server?: string
is_enabled?: boolean
client_id?: string
client_secret?: string
card_sectors?: string
api_server?: string
ttlock_username?: string
ttlock_password?: string
}
}>('/api/hotels/:slug/ttlock/config', { onRequest: [fastify.authenticate] }, async (req, reply) => {
const { slug } = req.params
@@ -78,25 +81,33 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
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(
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server)
VALUES ($1, $2, $3, $4, $5, $6)
`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, $7, $8)
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),
card_sectors = COALESCE($5, hotel_ttlock_config.card_sectors),
api_server = COALESCE($6, hotel_ttlock_config.api_server),
updated_at = NOW()`,
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),
api_server = COALESCE($6, hotel_ttlock_config.api_server),
ttlock_username = COALESCE(NULLIF($7, ''), hotel_ttlock_config.ttlock_username),
ttlock_password = COALESCE(NULLIF($8, ''), hotel_ttlock_config.ttlock_password),
updated_at = NOW()`,
[
hotelId,
isEnabled ?? null,
clientId ?? null,
clientSecret ?? '',
cardSectors ?? null,
apiServer ?? null,
isEnabled ?? null,
clientId ?? null,
clientSecret ?? '',
cardSectors ?? null,
apiServer ?? null,
ttlockUsername ?? '',
ttlockPassword ?? '',
],
)
return { ok: true }
@@ -173,7 +184,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
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],
)
const cfg = cfgRows[0]
@@ -184,10 +195,12 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
try {
const result = await sendCommandAndWait(workstationId, {
type: 'ttlock:test_api',
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
type: 'ttlock:test_api',
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
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 }
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)
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],
)
const cfg = cfgRows[0]
@@ -388,15 +401,17 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
let result: unknown
try {
result = await sendCommandAndWait(workstationId, {
type: 'ttlock:write_card',
lockMac: lock.lock_mac,
comPort: comPort,
cardSectors: cfg.card_sectors,
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
startDate: checkIn,
endDate: checkOut,
type: 'ttlock:write_card',
lockMac: lock.lock_mac,
comPort: comPort,
cardSectors: cfg.card_sectors,
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
ttlockUsername: cfg.ttlock_username ?? '',
ttlockPassword: cfg.ttlock_password ?? '',
apiServer: cfg.api_server ?? 'https://euapi.ttlock.com',
startDate: checkIn,
endDate: checkOut,
}, 30_000)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Агент не ответил'

View File

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

View File

@@ -139,11 +139,13 @@ export function TTLockPage() {
const [success, setSuccess] = useState<string | null>(null)
// Форма настроек
const [isEnabled, setIsEnabled] = useState(false)
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
const [apiServer, setApiServer] = useState('https://euapi.ttlock.com')
const [isEnabled, setIsEnabled] = useState(false)
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [ttlockUsername, setTtlockUsername] = useState('')
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
const [wsComPorts, setWsComPorts] = useState<Record<string, string>>({})
@@ -242,6 +244,7 @@ export function TTLockPage() {
setConfig(cfg)
setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId)
setTtlockUsername(cfg.ttlockUsername || '')
setCardSectors(cfg.cardSectors || '1,2,3,4,5,6,7,8,9,10')
setApiServer(cfg.apiServer || 'https://euapi.ttlock.com')
setMappings(maps)
@@ -263,13 +266,16 @@ export function TTLockPage() {
try {
await api.ttlock.updateConfig(slug, {
isEnabled,
clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined,
cardSectors: cardSectors.trim(),
apiServer: apiServer,
clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined,
ttlock_username: ttlockUsername.trim() || undefined,
ttlock_password: ttlockPassword.trim() || undefined,
cardSectors: cardSectors.trim(),
apiServer: apiServer,
})
showSuccess('Настройки сохранены')
setClientSecret('')
setTtlockPassword('')
await load()
} catch {
setError('Ошибка сохранения')
@@ -410,6 +416,27 @@ export function TTLockPage() {
</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 */}
<div>
<label className="form-label">Сервер TTLock API</label>