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