fmode-wecom-gateway.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. const { DEFAULT_API_BASE } = require('../core/credentials');
  2. function redactSecret(value) {
  3. return String(value || '')
  4. .replace(/Bearer\s+[^"'\s,}]+/gi, 'Bearer [REDACTED]')
  5. .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, 'sk-[REDACTED]')
  6. .replace(/\br:[A-Za-z0-9]{4,}\b/g, 'r:[REDACTED]')
  7. .replace(/("Authorization"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
  8. .replace(/("(?:apiKey|masterKey|password|secret|sessionToken|accessToken|refreshToken)"\s*:\s*")([^"]+)(")/gi, '$1[REDACTED]$3')
  9. .replace(/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|GUID)[A-Z0-9_]*)\s*=\s*([^\s\r\n]+)/g, '$1=[REDACTED]')
  10. .replace(/https?:\/\/[^\s"'))]*/gi, '[外部链接]')
  11. .replace(/\btokenId\b/gi, '服务凭据')
  12. .replace(/\bguid\b/gi, '设备标识');
  13. }
  14. function sanitizePayload(value, fieldName = '') {
  15. if (Array.isArray(value)) return value.map(item => sanitizePayload(item, fieldName));
  16. if (value && typeof value === 'object') {
  17. const output = {};
  18. for (const [key, item] of Object.entries(value)) {
  19. if (
  20. /^(tokenId|provider(?:Name|Host)?|vendor(?:Name|Host)?|upstream(?:Name|Host)?|source|supportUrl|docUrl|docsUrl|consoleUrl|apiHost|host|domain)$/i.test(
  21. key
  22. )
  23. ) {
  24. continue;
  25. }
  26. output[key] = sanitizePayload(item, key);
  27. }
  28. return output;
  29. }
  30. if (typeof value === 'string' && /^(uid|guid|userId|corpId)$/i.test(fieldName)) return value;
  31. return typeof value === 'string' ? redactSecret(value) : value;
  32. }
  33. function classifyError({ httpStatus, code, message }) {
  34. const status = Number(code) || Number(httpStatus) || 0;
  35. const text = String(message || '');
  36. if (status === 401 || /认证失败|登录失效|token.*(无效|失效|过期|错误)|未授权|unauthorized/i.test(text)) {
  37. return 'auth';
  38. }
  39. if (status === 402 || /余额不足|未开通.*订阅|订阅.*到期|自动续费失败|套餐|额度不足/i.test(text)) {
  40. return 'billing';
  41. }
  42. if (status === 403 || /席位已满|无权限|permission|forbidden/i.test(text)) {
  43. return 'permission';
  44. }
  45. if (/未登录|掉线|离线|设备不存在|uid=.*未登录/i.test(text)) {
  46. return 'device';
  47. }
  48. if ([400, 404, 422].includes(status) || /参数|缺少|必填|invalid|bad request/i.test(text)) {
  49. return 'request';
  50. }
  51. if (status >= 500 || httpStatus >= 500) return 'upstream';
  52. return 'business';
  53. }
  54. function buildGatewayUrl(apiBase, gatewayPath, query = {}) {
  55. const root = String(apiBase || DEFAULT_API_BASE).replace(/\/$/, '');
  56. const url = new URL(`${root}/${String(gatewayPath || '').replace(/^\//, '')}`);
  57. for (const [key, value] of Object.entries(query || {})) {
  58. if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
  59. }
  60. return url.toString();
  61. }
  62. function isSuccessCode(json) {
  63. if (!json || typeof json !== 'object' || json.code === undefined) return true;
  64. return [0, 200].includes(Number(json.code));
  65. }
  66. function publicErrorMessage(kind) {
  67. const messages = {
  68. auth: '鉴权失败',
  69. billing: '订阅或余额校验失败',
  70. permission: '席位或权限不足',
  71. device: '设备未登录或已离线',
  72. request: '请求参数错误',
  73. upstream: '企业微信服务暂时不可用',
  74. business: '企业微信业务请求失败'
  75. };
  76. return messages[kind] || messages.business;
  77. }
  78. const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
  79. async function fetchWithNetworkRetry(url, options, timeoutMs, attempts = 4) {
  80. let lastError;
  81. for (let attempt = 1; attempt <= attempts; attempt += 1) {
  82. const controller = new AbortController();
  83. const timer = setTimeout(() => controller.abort(), timeoutMs);
  84. try {
  85. return await fetch(url, { ...options, signal: controller.signal });
  86. } catch (error) {
  87. lastError = error;
  88. if (attempt < attempts) await delay(250 * attempt);
  89. } finally {
  90. clearTimeout(timer);
  91. }
  92. }
  93. throw lastError;
  94. }
  95. async function callFmodeWecomGateway({
  96. gatewayPath,
  97. httpMethod = 'POST',
  98. query,
  99. body,
  100. formData,
  101. token,
  102. apiBase,
  103. timeoutMs = 60000,
  104. networkAttempts = 4,
  105. cacheBust = false,
  106. preserveExternalUrls = false
  107. }) {
  108. if (!gatewayPath) {
  109. const err = new Error('missing gatewayPath');
  110. err.kind = 'request';
  111. throw err;
  112. }
  113. if (!token) {
  114. const err = new Error('missing fmode authorization token');
  115. err.kind = 'auth';
  116. err.httpStatus = 401;
  117. throw err;
  118. }
  119. const requestQuery = cacheBust
  120. ? { ...(query || {}), _ts: Date.now() }
  121. : query;
  122. const url = buildGatewayUrl(apiBase, gatewayPath, requestQuery);
  123. const method = String(httpMethod || 'POST').toUpperCase();
  124. if (body !== undefined && formData !== undefined) {
  125. const err = new Error('body and formData are mutually exclusive');
  126. err.kind = 'request';
  127. throw err;
  128. }
  129. let response;
  130. try {
  131. response = await fetchWithNetworkRetry(url, {
  132. method,
  133. headers: {
  134. Authorization: `Bearer ${token}`,
  135. Accept: 'application/json',
  136. ...(cacheBust ? { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } : {}),
  137. ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
  138. },
  139. body: formData !== undefined
  140. ? formData
  141. : body === undefined
  142. ? undefined
  143. : JSON.stringify(body)
  144. }, timeoutMs, Math.max(1, Math.min(4, Number(networkAttempts) || 1)));
  145. } catch (error) {
  146. const err = new Error('网络请求失败');
  147. err.kind = 'upstream';
  148. err.httpStatus = 0;
  149. throw err;
  150. }
  151. const text = await response.text();
  152. let json;
  153. try {
  154. json = JSON.parse(text);
  155. } catch {
  156. json = undefined;
  157. }
  158. if (!response.ok || !isSuccessCode(json)) {
  159. const code = json && json.code !== undefined ? Number(json.code) : response.status;
  160. const message =
  161. (json && (json.mess || json.msg || json.message || json.data?.mess || json.data?.message)) ||
  162. text.slice(0, 500) ||
  163. `HTTP ${response.status}`;
  164. const kind = classifyError({ httpStatus: response.status, code, message });
  165. const err = new Error(publicErrorMessage(kind));
  166. err.kind = kind;
  167. err.httpStatus = code || response.status;
  168. err.bizCode = code;
  169. err.bizMessage = message;
  170. throw err;
  171. }
  172. // Signed media URLs are retained only for trusted internal consumers.
  173. const safeJson = preserveExternalUrls ? json : sanitizePayload(json);
  174. return {
  175. httpStatus: response.status,
  176. json: safeJson,
  177. data: safeJson && safeJson.data !== undefined ? safeJson.data : safeJson || redactSecret(text)
  178. };
  179. }
  180. module.exports = {
  181. callFmodeWecomGateway,
  182. buildGatewayUrl,
  183. redactSecret,
  184. sanitizePayload,
  185. classifyError
  186. };