| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200 |
- const { DEFAULT_API_BASE } = require('../core/credentials');
- function redactSecret(value) {
- return String(value || '')
- .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
- .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
- .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
- .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
- .replace(/("(?:apiKey|masterKey|password|secret|sessionToken|accessToken|refreshToken)"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
- .replace(/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|GUID)[A-Z0-9_]*)\s*=\s*([^\s\r\n]+)/g, '$1=[REDACTED]')
- .replace(/https?:\/\/[^\s"'))]*/gi, '[外部链接]')
- .replace(/\btokenId\b/gi, '服务凭据')
- .replace(/\bguid\b/gi, '设备标识');
- }
- function sanitizePayload(value, fieldName = '') {
- if (Array.isArray(value)) return value.map(item => sanitizePayload(item, fieldName));
- if (value && typeof value === 'object') {
- const output = {};
- for (const [key, item] of Object.entries(value)) {
- if (
- /^(tokenId|provider(?:Name|Host)?|vendor(?:Name|Host)?|upstream(?:Name|Host)?|source|supportUrl|docUrl|docsUrl|consoleUrl|apiHost|host|domain)$/i.test(
- key
- )
- ) {
- continue;
- }
- output[key] = sanitizePayload(item, key);
- }
- return output;
- }
- if (typeof value === 'string' && /^(uid|guid|userId|corpId)$/i.test(fieldName)) return value;
- return typeof value === 'string' ? redactSecret(value) : value;
- }
- function classifyError({ httpStatus, code, message }) {
- const status = Number(code) || Number(httpStatus) || 0;
- const text = String(message || '');
- if (status === 401 || /认证失败|登录失效|token.*(无效|失效|过期|错误)|未授权|unauthorized/i.test(text)) {
- return 'auth';
- }
- if (status === 402 || /余额不足|未开通.*订阅|订阅.*到期|自动续费失败|套餐|额度不足/i.test(text)) {
- return 'billing';
- }
- if (status === 403 || /席位已满|无权限|permission|forbidden/i.test(text)) {
- return 'permission';
- }
- if (/未登录|掉线|离线|设备不存在|uid=.*未登录/i.test(text)) {
- return 'device';
- }
- if ([400, 404, 422].includes(status) || /参数|缺少|必填|invalid|bad request/i.test(text)) {
- return 'request';
- }
- if (status >= 500 || httpStatus >= 500) return 'upstream';
- return 'business';
- }
- function buildGatewayUrl(apiBase, gatewayPath, query = {}) {
- const root = String(apiBase || DEFAULT_API_BASE).replace(/\/$/, '');
- const url = new URL(`${root}/${String(gatewayPath || '').replace(/^\//, '')}`);
- for (const [key, value] of Object.entries(query || {})) {
- if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
- }
- return url.toString();
- }
- function isSuccessCode(json) {
- if (!json || typeof json !== 'object' || json.code === undefined) return true;
- return [0, 200].includes(Number(json.code));
- }
- function publicErrorMessage(kind) {
- const messages = {
- auth: '鉴权失败',
- billing: '订阅或余额校验失败',
- permission: '席位或权限不足',
- device: '设备未登录或已离线',
- request: '请求参数错误',
- upstream: '企业微信服务暂时不可用',
- business: '企业微信业务请求失败'
- };
- return messages[kind] || messages.business;
- }
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
- async function fetchWithNetworkRetry(url, options, timeoutMs, attempts = 4) {
- let lastError;
- for (let attempt = 1; attempt <= attempts; attempt += 1) {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
- try {
- return await fetch(url, { ...options, signal: controller.signal });
- } catch (error) {
- lastError = error;
- if (attempt < attempts) await delay(250 * attempt);
- } finally {
- clearTimeout(timer);
- }
- }
- throw lastError;
- }
- async function callFmodeWecomGateway({
- gatewayPath,
- httpMethod = 'POST',
- query,
- body,
- formData,
- token,
- apiBase,
- timeoutMs = 60000,
- networkAttempts = 4,
- cacheBust = false,
- preserveExternalUrls = false
- }) {
- if (!gatewayPath) {
- const err = new Error('missing gatewayPath');
- err.kind = 'request';
- throw err;
- }
- if (!token) {
- const err = new Error('missing fmode authorization token');
- err.kind = 'auth';
- err.httpStatus = 401;
- throw err;
- }
- const requestQuery = cacheBust
- ? { ...(query || {}), _ts: Date.now() }
- : query;
- const url = buildGatewayUrl(apiBase, gatewayPath, requestQuery);
- const method = String(httpMethod || 'POST').toUpperCase();
- if (body !== undefined && formData !== undefined) {
- const err = new Error('body and formData are mutually exclusive');
- err.kind = 'request';
- throw err;
- }
- let response;
- try {
- response = await fetchWithNetworkRetry(url, {
- method,
- headers: {
- Authorization: `Bearer ${token}`,
- Accept: 'application/json',
- ...(cacheBust ? { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } : {}),
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
- },
- body: formData !== undefined
- ? formData
- : body === undefined
- ? undefined
- : JSON.stringify(body)
- }, timeoutMs, Math.max(1, Math.min(4, Number(networkAttempts) || 1)));
- } catch (error) {
- const err = new Error('网络请求失败');
- err.kind = 'upstream';
- err.httpStatus = 0;
- throw err;
- }
- const text = await response.text();
- let json;
- try {
- json = JSON.parse(text);
- } catch {
- json = undefined;
- }
- if (!response.ok || !isSuccessCode(json)) {
- const code = json && json.code !== undefined ? Number(json.code) : response.status;
- const message =
- (json && (json.mess || json.msg || json.message || json.data?.mess || json.data?.message)) ||
- text.slice(0, 500) ||
- `HTTP ${response.status}`;
- const kind = classifyError({ httpStatus: response.status, code, message });
- const err = new Error(publicErrorMessage(kind));
- err.kind = kind;
- err.httpStatus = code || response.status;
- err.bizCode = code;
- err.bizMessage = message;
- throw err;
- }
- // Signed media URLs are retained only for trusted internal consumers.
- const safeJson = preserveExternalUrls ? json : sanitizePayload(json);
- return {
- httpStatus: response.status,
- json: safeJson,
- data: safeJson && safeJson.data !== undefined ? safeJson.data : safeJson || redactSecret(text)
- };
- }
- module.exports = {
- callFmodeWecomGateway,
- buildGatewayUrl,
- redactSecret,
- sanitizePayload,
- classifyError
- };
|