deploy: change method to get status from device

This commit is contained in:
2025-10-15 10:54:05 +07:00
parent c5545ddde2
commit c167f4ec43
6 changed files with 32 additions and 260 deletions
-191
View File
@@ -1,191 +0,0 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { httpConfig } from '~/config'
import logger from '~/plugins/logger'
interface AxiosConfigWithMetadata extends AxiosRequestConfig {
metadata?: {
startTime: number
}
}
interface HttpRequestOptions {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
url: string
body?: Record<string, unknown>
config?: AxiosRequestConfig
}
interface HttpResponse<T = unknown> {
data: T
status: number
statusText: string
headers: Record<string, string>
}
class HttpError extends Error {
constructor(
message: string,
public status: number,
public response?: AxiosResponse,
) {
super(message)
this.name = 'HttpError'
}
}
let axiosInstance: AxiosInstance | null = null
function getAxiosInstance(): AxiosInstance {
if (!axiosInstance) {
axiosInstance = axios.create({
baseURL: httpConfig.baseUrl,
timeout: 30000, // 30 seconds default timeout
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
})
// Add basic auth if configured
if (httpConfig.username && httpConfig.password) {
const auth = Buffer.from(`${httpConfig.username}:${httpConfig.password}`).toString('base64')
axiosInstance.defaults.headers.common['Authorization'] = `Basic ${auth}`
}
// Request interceptor for logging
axiosInstance.interceptors.request.use(
(config) => {
;(config as AxiosConfigWithMetadata).metadata = { startTime: Date.now() }
logger.info({
msg: 'HTTP Request',
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
headers: config.headers,
params: config.params,
data: config.data,
})
return config
},
(error) => {
logger.error({
msg: 'HTTP Request Error',
error: error.message,
config: {
method: error.config?.method,
url: error.config?.url,
baseURL: error.config?.baseURL,
},
})
return Promise.reject(error)
},
)
// Response interceptor for logging
axiosInstance.interceptors.response.use(
(response) => {
const startTime = (response.config as AxiosConfigWithMetadata).metadata?.startTime
logger.info({
msg: 'HTTP Response',
status: response.status,
statusText: response.statusText,
method: response.config.method?.toUpperCase(),
url: response.config.url,
baseURL: response.config.baseURL,
responseTime: startTime ? Date.now() - startTime : undefined,
})
return response
},
(error) => {
const startTime = (error.config as AxiosConfigWithMetadata)?.metadata?.startTime
logger.error({
msg: 'HTTP Response Error',
status: error.response?.status,
statusText: error.response?.statusText,
method: error.config?.method?.toUpperCase(),
url: error.config?.url,
baseURL: error.config?.baseURL,
error: error.message,
responseData: error.response?.data,
responseTime: startTime ? Date.now() - startTime : undefined,
})
return Promise.reject(error)
},
)
}
return axiosInstance
}
async function httpRequest<T = unknown>(options: HttpRequestOptions): Promise<HttpResponse<T>> {
const { method, url, body, config = {} } = options
const instance = getAxiosInstance()
const axiosConfig: AxiosRequestConfig = {
method,
url,
...config,
}
if (body && ['POST', 'PUT', 'PATCH'].includes(method)) {
axiosConfig.data = body
}
if (method === 'GET' && body) {
axiosConfig.params = body
}
const maxRetries = 3
let lastError: Error | null = null
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response: AxiosResponse<T> = await instance.request(axiosConfig)
return {
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers as Record<string, string>,
}
} catch (error) {
lastError = error as Error
// Check if it's a transient error worth retrying
const isRetryable = axios.isAxiosError(error)
&& (
!error.response // Network errors
|| error.response.status >= 500 // Server errors
|| error.response.status === 429 // Rate limiting
)
if (!isRetryable || attempt === maxRetries) {
break
}
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000
logger.warn({
msg: 'HTTP Retry',
attempt,
maxRetries,
delay,
method,
url,
error: lastError.message,
})
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
// If we get here, all retries failed
if (lastError && axios.isAxiosError(lastError)) {
const status = lastError.response?.status || 0
const message = lastError.response?.data?.message || lastError.message
throw new HttpError(`HTTP ${method} ${url} failed: ${message}`, status, lastError.response)
}
throw new HttpError(`HTTP ${method} ${url} failed: ${lastError?.message || 'Unknown error'}`, 0)
}
export { httpRequest, HttpError }
export type { HttpRequestOptions, HttpResponse }
-13
View File
@@ -123,19 +123,6 @@ export function sign(data: unknown, secretKey?: string): string {
return hmac.digest('hex') return hmac.digest('hex')
} }
console.log(sign({
merchantName: 'SAVY',
floorName: 'L2',
unitNumber: '01',
deviceName: 'AC',
roomName: 'KN',
deviceType: 'A',
commandType: 'C',
towerNumber: 'T1',
timestamp: '2025-10-14T04:06:52.022Z',
nonce: '50ce476cd1c355ae552ae18be55af72c',
}, '458fd644689a456efa87d38f788511d9'))
/** /**
* Verifies the signature of the data using HMAC-SHA256 with constant-time comparison. * Verifies the signature of the data using HMAC-SHA256 with constant-time comparison.
* @param data The data to verify. * @param data The data to verify.
-6
View File
@@ -5,12 +5,6 @@ export const deviceGuard = () => ({
beforeHandle(context: unknown) { beforeHandle(context: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const { headers, set } = context as any const { headers, set } = context as any
// Validate Content-Type
if (headers['content-type'] !== 'application/json') {
set.status = 400
throw new Error('invalid_request')
}
// Validate X-API-Key // Validate X-API-Key
const apiKey = headers['x-api-key'] const apiKey = headers['x-api-key']
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') { if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {
+8 -5
View File
@@ -1,16 +1,19 @@
import { httpRequest } from '~/helpers/http/axios' import { httpConfig } from '~/config'
import logger from '~/plugins/logger' import logger from '~/plugins/logger'
export async function sendCommandToThirdParty(topicData: string, payload: string): Promise<void> { export async function sendCommandToThirdParty(topicData: string, payload: string): Promise<void> {
try { try {
const response = await httpRequest({ const response = await fetch(`${httpConfig.baseUrl}/command`, {
method: 'POST', method: 'POST',
url: '/command', headers: {
body: { 'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: topicData, topic: topicData,
payload, payload,
}, }),
}) })
logger.info({ msg: 'Third-party command sent successfully', topic: topicData, status: response.status }) logger.info({ msg: 'Third-party command sent successfully', topic: topicData, status: response.status })
} catch (error) { } catch (error) {
logger.error({ msg: 'Failed to send command to third-party service', topic: topicData, error: (error as Error).message }) logger.error({ msg: 'Failed to send command to third-party service', topic: topicData, error: (error as Error).message })
+22 -28
View File
@@ -1,7 +1,7 @@
import { httpRequest } from '~/helpers/http/axios' import { omit } from 'lodash-es'
import logger from '~/plugins/logger' import { httpConfig } from '~/config'
export async function queryDeviceStatus(topic: string): Promise<{ interface DeviceStatus {
floor: string floor: string
unit: string unit: string
device: string device: string
@@ -10,34 +10,28 @@ export async function queryDeviceStatus(topic: string): Promise<{
code: string code: string
tower: string tower: string
payload: string payload: string
} | null> { topics?: unknown
try { createAt?: unknown
const response = await httpRequest({ }
method: 'GET',
url: '/state-reply', export async function queryDeviceStatus(topic: string): Promise<Omit<DeviceStatus, 'topics' | 'createAt'> | null> {
body: { topic }, const response = await fetch(`${httpConfig.baseUrl}/state-reply`, {
method: 'POST',
body: JSON.stringify({ topic }),
headers: {
'Content-Type': 'application/json',
},
}) })
if ((response.data as Record<string, unknown>) && (response.data as Record<string, unknown>).data) { if (!response.ok) {
const thirdPartyData = (response.data as Record<string, unknown>).data as Record<string, unknown> throw new Error('internal_server_error')
logger.info({ msg: 'Device status queried successfully', topic, status: response.status })
return {
floor: thirdPartyData.floor as string,
unit: thirdPartyData.unit as string,
device: thirdPartyData.device as string,
room: thirdPartyData.room as string,
devType: thirdPartyData.devType as string,
code: thirdPartyData.code as string,
tower: thirdPartyData.tower as string,
payload: thirdPartyData.payload as string,
} }
} else {
logger.info({ msg: 'Device status not found', topic, status: response.status }) const json = await response.json()
if (!json || !json.data) {
return null return null
} }
} catch (error) {
logger.error({ msg: 'Failed to query device status', topic, error: (error as Error).message }) return omit(json.data as DeviceStatus, ['topics', 'createAt'])
throw new Error('third_party_query_failed')
}
} }
-15
View File
@@ -25,27 +25,12 @@ export const swaggerPlugin = openapi({
], ],
'components': { 'components': {
securitySchemes: { securitySchemes: {
basicAuth: {
type: 'http',
scheme: 'basic',
},
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
apiKey: { apiKey: {
type: 'apiKey', type: 'apiKey',
in: 'header', in: 'header',
name: 'X-API-Key', name: 'X-API-Key',
description: 'API key for authentication', description: 'API key for authentication',
}, },
timestamp: {
type: 'apiKey',
in: 'header',
name: 'X-Timestamp',
description: 'UTC timestamp in ISO8601 format',
},
}, },
}, },
} as OpenApiDocumentation, } as OpenApiDocumentation,