'use strict'; const crypto = require('crypto'); const axios = require('axios'); function isTruthy(value) { return value === true || value === 1 || String(value || '').trim().toLowerCase() === 'true' || String(value || '').trim() === '1'; } function parsePositiveInt(value, fallback) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function normalizeBaseUrl(baseUrl) { return String(baseUrl || '').trim().replace(/\/+$/, ''); } function normalizePath(path) { const raw = String(path || '').trim(); if (!raw) { return '/'; } return raw.startsWith('/') ? raw : `/${raw}`; } function buildRequestUrl(baseUrl, path) { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); const normalizedPath = normalizePath(path); return `${normalizedBaseUrl}${normalizedPath}`; } function resolveSignPath(baseUrl, path) { const requestUrl = buildRequestUrl(baseUrl, path); try { const parsed = new URL(requestUrl); return parsed.pathname || normalizePath(path); } catch (_error) { return normalizePath(path); } } function buildJsonRequestBody(body) { if (body === null || body === undefined) { return null; } if (Buffer.isBuffer(body)) { return body; } if (typeof body === 'string') { return Buffer.from(body, 'utf8'); } return Buffer.from(stringifyJsonWithUnicodeEscapes(body), 'utf8'); } function stringifyJsonWithUnicodeEscapes(value) { return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => ( `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` )); } function createNonce() { return crypto.randomBytes(16).toString('hex'); } function createTimestamp(useUnixMillisecondsTimestamp = false) { const now = Date.now(); return String(useUnixMillisecondsTimestamp ? now : Math.floor(now / 1000)); } function buildSign(path, timespan, nonce, secret) { const normalizedPath = normalizePath(path); const payload = `${normalizedPath}${timespan}${nonce}${secret}`; return crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex') .toLowerCase(); } function readFirstEnv(source, names, fallback = '') { for (const name of names) { const value = source[name]; if (value !== undefined && value !== null && String(value).trim() !== '') { return value; } } return fallback; } function buildWorkOrderApiOptionsFromEnv(source = process.env) { return { baseUrl: readFirstEnv(source, ['WORK_ORDER_API_BASE_URL', 'WORK_ORDER_BASE_URL']), signKey: readFirstEnv(source, ['WORK_ORDER_API_SIGN_KEY', 'WORK_ORDER_SIGN_KEY']), accessTokenPath: readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_PATH', 'WORK_ORDER_TOKEN_PATH'], '/auth/accessToken'), appKey: readFirstEnv(source, ['WORK_ORDER_API_APP_KEY', 'WORK_ORDER_APP_KEY']), appSecret: readFirstEnv(source, ['WORK_ORDER_API_APP_SECRET', 'WORK_ORDER_APP_SECRET']), accessTokenHeaderName: readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_HEADER_NAME', 'WORK_ORDER_ACCESS_TOKEN_HEADER_NAME'], 'work_order_accesstoken'), accessTokenDefaultTtlMinutes: parsePositiveInt(readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_DEFAULT_TTL_MINUTES', 'WORK_ORDER_ACCESS_TOKEN_DEFAULT_TTL_MINUTES']), 30), accessTokenRefreshSkewSeconds: parsePositiveInt(readFirstEnv(source, ['WORK_ORDER_API_ACCESS_TOKEN_REFRESH_SKEW_SECONDS', 'WORK_ORDER_ACCESS_TOKEN_REFRESH_SKEW_SECONDS']), 60), createWorkOrderPath: readFirstEnv(source, ['WORK_ORDER_API_CREATE_PATH', 'WORK_ORDER_CREATE_PATH'], '/workorder/create'), timeoutSeconds: parsePositiveInt(readFirstEnv(source, ['WORK_ORDER_API_TIMEOUT_SECONDS', 'WORK_ORDER_TIMEOUT_SECONDS']), 30), useUnixMillisecondsTimestamp: isTruthy(readFirstEnv(source, ['WORK_ORDER_API_USE_UNIX_MILLISECONDS_TIMESTAMP', 'WORK_ORDER_USE_UNIX_MILLISECONDS_TIMESTAMP'])), }; } class WorkOrderApiError extends Error { constructor(message, details = {}) { super(message); this.name = 'WorkOrderApiError'; this.statusCode = details.statusCode || null; this.businessCode = details.businessCode ?? null; this.businessMessage = details.businessMessage || ''; this.rawContent = details.rawContent || ''; this.requestPath = details.requestPath || ''; this.responseData = details.responseData; } } class WorkOrderApiClient { constructor(options = {}) { this.options = { ...buildWorkOrderApiOptionsFromEnv(), ...options, }; this.options.baseUrl = normalizeBaseUrl(this.options.baseUrl); this.options.accessTokenPath = normalizePath(this.options.accessTokenPath); this.options.createWorkOrderPath = normalizePath(this.options.createWorkOrderPath); this._cachedAccessToken = ''; this._cachedAccessTokenExpiresAt = 0; this._pendingAccessTokenPromise = null; this.httpClient = axios.create({ timeout: Math.max(1000, Number(this.options.timeoutSeconds || 30) * 1000), validateStatus: () => true, }); } getConfigurationSnapshot() { return { baseUrl: this.options.baseUrl, accessTokenPath: this.options.accessTokenPath, createWorkOrderPath: this.options.createWorkOrderPath, accessTokenHeaderName: this.options.accessTokenHeaderName, timeoutSeconds: this.options.timeoutSeconds, useUnixMillisecondsTimestamp: Boolean(this.options.useUnixMillisecondsTimestamp), hasSignKey: Boolean(this.options.signKey), hasAppKey: Boolean(this.options.appKey), hasAppSecret: Boolean(this.options.appSecret), }; } clearAccessTokenCache() { this._cachedAccessToken = ''; this._cachedAccessTokenExpiresAt = 0; } async createWorkOrder(workOrderRequest, options = {}) { const accessToken = await this.getAccessToken(false); try { return await this.sendSignedRequest( 'post', this.options.createWorkOrderPath, workOrderRequest, accessToken, options ); } catch (error) { if (!this.needsTokenRefresh(error)) { throw error; } const refreshedAccessToken = await this.getAccessToken(true); return this.sendSignedRequest( 'post', this.options.createWorkOrderPath, workOrderRequest, refreshedAccessToken, options ); } } async getAccessToken(forceRefresh = false) { this.validateConfiguration(this.options.accessTokenPath, true); if (!forceRefresh && this.hasUsableCachedAccessToken()) { return this._cachedAccessToken; } if (!forceRefresh && this._pendingAccessTokenPromise) { return this._pendingAccessTokenPromise; } const promise = (async () => { const result = await this.sendSignedRequest( 'post', this.options.accessTokenPath, { appkey: this.options.appKey, appsecret: this.options.appSecret, }, null ); const accessToken = this.extractAccessToken(result.businessData); if (!accessToken) { throw new WorkOrderApiError('工单 accessToken 接口返回成功,但未解析到 token', { businessCode: result.businessCode, businessMessage: result.businessMessage, rawContent: result.rawContent, requestPath: result.requestPath, responseData: result.responseData, }); } this._cachedAccessToken = accessToken; this._cachedAccessTokenExpiresAt = this.resolveAccessTokenExpiration(result.businessData); return accessToken; })(); this._pendingAccessTokenPromise = promise; try { return await promise; } finally { if (this._pendingAccessTokenPromise === promise) { this._pendingAccessTokenPromise = null; } } } hasUsableCachedAccessToken() { if (!this._cachedAccessToken) { return false; } const skewMs = Math.max(0, Number(this.options.accessTokenRefreshSkewSeconds || 0)) * 1000; return Date.now() < (this._cachedAccessTokenExpiresAt - skewMs); } needsTokenRefresh(error) { if (!(error instanceof WorkOrderApiError)) { return false; } return Number(error.businessCode) === -1 && String(error.businessMessage || '').includes('登录过期'); } async sendSignedRequest(method, path, body = null, accessToken = null, requestOptions = {}) { this.validateConfiguration(path, false); const normalizedPath = normalizePath(path); const url = buildRequestUrl(this.options.baseUrl, normalizedPath); const signPath = resolveSignPath(this.options.baseUrl, normalizedPath); const timespan = createTimestamp(this.options.useUnixMillisecondsTimestamp); const nonce = createNonce(); const sign = buildSign(signPath, timespan, nonce, this.options.signKey); const headers = { sign, timespan, nonce, ...(requestOptions.headers || {}), }; if (accessToken) { headers[this.options.accessTokenHeaderName] = accessToken; } const requestBody = buildJsonRequestBody(body); if (requestBody !== null) { if (!headers['Content-Type'] && !headers['content-type']) { headers['Content-Type'] = 'application/json; charset=utf-8'; } if (!headers['Accept'] && !headers['accept']) { headers.Accept = 'application/json'; } if (!headers['Accept-Charset'] && !headers['accept-charset']) { headers['Accept-Charset'] = 'utf-8'; } headers['Content-Length'] = String(requestBody.length); } const response = await this.httpClient.request({ method, url, data: requestBody, headers, timeout: requestOptions.timeoutMs || this.httpClient.defaults.timeout, responseType: 'text', }); const rawContent = typeof response.data === 'string' ? response.data : JSON.stringify(response.data ?? {}); if (response.status < 200 || response.status >= 300) { throw new WorkOrderApiError(`工单接口请求失败: HTTP ${response.status}`, { statusCode: response.status, rawContent, requestPath: signPath, responseData: response.data, }); } const envelope = this.tryDeserializeEnvelope(response.data); if (envelope && envelope.code !== undefined && envelope.code !== null && Number(envelope.code) !== 0) { throw new WorkOrderApiError( `工单接口业务失败: code=${envelope.code}, message=${envelope.message || ''}`, { statusCode: response.status, businessCode: Number(envelope.code), businessMessage: envelope.message || '', rawContent, requestPath: signPath, responseData: response.data, } ); } return { statusCode: response.status, rawContent, requestPath: signPath, timespan, nonce, businessCode: envelope?.code ?? null, businessMessage: envelope?.message || '', businessData: envelope?.data, responseData: response.data, }; } tryDeserializeEnvelope(responseData) { if (responseData === null || responseData === undefined) { return null; } if (typeof responseData === 'object' && !Array.isArray(responseData)) { return responseData; } if (typeof responseData !== 'string' || !responseData.trim()) { return null; } try { return JSON.parse(responseData); } catch (_error) { return null; } } extractAccessToken(data) { if (!data) { return ''; } if (typeof data === 'string') { return data.trim(); } return this.readNestedValue(data, [ 'accessToken' ]) || this.readNestedValue(data, [ 'access_token' ]) || this.readNestedValue(data, [ 'token' ]) || this.readNestedValue(data, [ 'data', 'accessToken' ]) || this.readNestedValue(data, [ 'data', 'access_token' ]) || this.readNestedValue(data, [ 'data', 'token' ]) || ''; } resolveAccessTokenExpiration(data) { const fallbackMs = Date.now() + (Math.max(1, Number(this.options.accessTokenDefaultTtlMinutes || 30)) * 60 * 1000); if (!data || typeof data !== 'object') { return fallbackMs; } const expiresIn = this.readNumberValue(data, [ 'expiresIn' ]) ?? this.readNumberValue(data, [ 'expires_in' ]) ?? this.readNumberValue(data, [ 'expireSeconds' ]) ?? this.readNumberValue(data, [ 'expire_seconds' ]); if (expiresIn && expiresIn > 0) { return Date.now() + (expiresIn * 1000); } const expiresAt = this.readNestedValue(data, [ 'expireTime' ]) || this.readNestedValue(data, [ 'expire_time' ]) || this.readNestedValue(data, [ 'expiresAt' ]) || this.readNestedValue(data, [ 'expires_at' ]); if (expiresAt) { const parsed = new Date(expiresAt).getTime(); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } } return fallbackMs; } readNestedValue(source, pathSegments) { let current = source; for (const segment of pathSegments) { if (!current || typeof current !== 'object' || !(segment in current)) { return ''; } current = current[segment]; } if (current === null || current === undefined) { return ''; } return String(current).trim(); } readNumberValue(source, pathSegments) { const value = this.readNestedValue(source, pathSegments); if (!value) { return null; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } validateConfiguration(path, requiresAccessTokenConfig) { if (!this.options.baseUrl) { throw new WorkOrderApiError(`WORK_ORDER_API_BASE_URL 未配置,无法调用 ${path}`); } if (!this.options.signKey) { throw new WorkOrderApiError(`WORK_ORDER_API_SIGN_KEY 未配置,无法调用 ${path}`); } if (!requiresAccessTokenConfig) { return; } if (!this.options.appKey) { throw new WorkOrderApiError(`WORK_ORDER_API_APP_KEY 未配置,无法调用 ${path}`); } if (!this.options.appSecret) { throw new WorkOrderApiError(`WORK_ORDER_API_APP_SECRET 未配置,无法调用 ${path}`); } } } function createDefaultWorkOrderApiClient(options = {}) { return new WorkOrderApiClient(options); } module.exports = { WorkOrderApiClient, WorkOrderApiError, buildSign, buildWorkOrderApiOptionsFromEnv, createDefaultWorkOrderApiClient, stringifyJsonWithUnicodeEscapes, };