forked from lunanting/cangku
462 lines
14 KiB
JavaScript
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,
|
|
};
|