cangku/backend/src/services/workOrderApiClient.js

462 lines
14 KiB
JavaScript

'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,
};