feat: TTLock API server selector (EU vs China) + pass apiServer to agent

- Add api_server column to hotel_ttlock_config (migration 053)
- GET/PATCH config includes apiServer field
- issue-card passes apiServer to agent command
- Agent: WriteCardCmd.apiServer optional, all API calls use it
- TTLockPage: radio buttons EU/China with hint about 404 error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 23:48:37 +03:00
parent 1da3373f29
commit a986470f8e
4 changed files with 53 additions and 5 deletions

View File

@@ -0,0 +1,3 @@
-- TTLock: выбор сервера API (EU или China)
ALTER TABLE hotel_ttlock_config
ADD COLUMN IF NOT EXISTS api_server TEXT NOT NULL DEFAULT 'https://euopen.ttlock.com';

View File

@@ -47,17 +47,18 @@ 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 FROM hotel_ttlock_config WHERE hotel_id = $1`, `SELECT is_enabled, client_id, card_sectors, api_server 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' } return { isEnabled: false, clientId: '', cardSectors: '1,2,3,4,5,6,7,8,9,10', apiServer: 'https://euopen.ttlock.com' }
} }
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://euopen.ttlock.com',
} }
}) })
@@ -68,6 +69,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
client_id?: string client_id?: string
client_secret?: string client_secret?: string
card_sectors?: string card_sectors?: string
api_server?: 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
@@ -76,16 +78,17 @@ 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 } = req.body const { is_enabled: isEnabled, client_id: clientId, client_secret: clientSecret, card_sectors: cardSectors, api_server: apiServer } = req.body
await db.query( await db.query(
`INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors) `INSERT INTO hotel_ttlock_config (hotel_id, is_enabled, client_id, client_secret, card_sectors, api_server)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
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),
updated_at = NOW()`, updated_at = NOW()`,
[ [
hotelId, hotelId,
@@ -93,6 +96,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
clientId ?? null, clientId ?? null,
clientSecret ?? '', clientSecret ?? '',
cardSectors ?? null, cardSectors ?? null,
apiServer ?? null,
], ],
) )
return { ok: true } return { ok: true }
@@ -322,6 +326,7 @@ const ttlockRoutes: FastifyPluginAsync = async (fastify) => {
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://euopen.ttlock.com',
startDate: checkIn, startDate: checkIn,
endDate: checkOut, endDate: checkOut,
}, 30_000) }, 30_000)

View File

@@ -1091,6 +1091,7 @@ export interface TTLockConfig {
isEnabled: boolean isEnabled: boolean
clientId: string clientId: string
cardSectors: string cardSectors: string
apiServer: string
} }
export interface TTLockConfigUpdate { export interface TTLockConfigUpdate {
@@ -1098,6 +1099,7 @@ export interface TTLockConfigUpdate {
clientId?: string clientId?: string
clientSecret?: string clientSecret?: string
cardSectors?: string cardSectors?: string
apiServer?: string
} }
export interface RoomLockMapping { export interface RoomLockMapping {

View File

@@ -142,6 +142,7 @@ export function TTLockPage() {
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 [cardSectors, setCardSectors] = useState('1,2,3,4,5,6,7,8,9,10')
const [apiServer, setApiServer] = useState('https://euopen.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>>({})
@@ -241,6 +242,7 @@ export function TTLockPage() {
setIsEnabled(cfg.isEnabled) setIsEnabled(cfg.isEnabled)
setClientId(cfg.clientId) setClientId(cfg.clientId)
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://euopen.ttlock.com')
setMappings(maps) setMappings(maps)
setRooms(roomList) setRooms(roomList)
setWorkstations(wsList) setWorkstations(wsList)
@@ -263,6 +265,7 @@ export function TTLockPage() {
clientId: clientId.trim(), clientId: clientId.trim(),
clientSecret: clientSecret.trim() || undefined, clientSecret: clientSecret.trim() || undefined,
cardSectors: cardSectors.trim(), cardSectors: cardSectors.trim(),
apiServer: apiServer,
}) })
showSuccess('Настройки сохранены') showSuccess('Настройки сохранены')
setClientSecret('') setClientSecret('')
@@ -391,6 +394,41 @@ export function TTLockPage() {
</div> </div>
</div> </div>
{/* Сервер TTLock API */}
<div>
<label className="form-label">Сервер TTLock API</label>
<div className="flex gap-3 mt-1">
{[
{ value: 'https://euopen.ttlock.com', label: 'EU', hint: 'euopen.ttlock.com' },
{ value: 'https://open.ttlock.com', label: 'China', hint: 'open.ttlock.com' },
].map(opt => (
<label
key={opt.value}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-lg border-2 cursor-pointer transition-colors select-none',
apiServer === opt.value
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300'
: 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300',
)}
>
<input
type="radio"
name="apiServer"
value={opt.value}
checked={apiServer === opt.value}
onChange={() => setApiServer(opt.value)}
className="sr-only"
/>
<span className="font-semibold text-sm">{opt.label}</span>
<span className="text-xs font-mono opacity-60">{opt.hint}</span>
</label>
))}
</div>
<p className="text-xs text-slate-400 mt-1.5">
Если получаете ошибку 404 при выдаче карты попробуйте другой сервер.
</p>
</div>
{/* Секторы карты — визуальный пикер */} {/* Секторы карты — визуальный пикер */}
<div> <div>
<label className="form-label">Секторы карты</label> <label className="form-label">Секторы карты</label>