lepaoSchoolHttp.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. const axios = require('axios')
  2. const QgProxyManager = require('./QgProxyManager')
  3. const { buildAxiosOutboundConfig } = require('./outboundAxiosConfig')
  4. function sleep(ms) {
  5. return new Promise(r => setTimeout(r, ms))
  6. }
  7. /** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动(青果函数内部已短时持锁 backoff,此处不宜再大) */
  8. async function getOutboundWithBackoff(qgOpts, rounds = 2) {
  9. let lastErr
  10. for (let i = 0; i < rounds; i++) {
  11. try {
  12. if (i > 0) await sleep(380 * i * i)
  13. return await QgProxyManager.getOutboundAxiosFragment(qgOpts)
  14. } catch (e) {
  15. lastErr = e
  16. }
  17. }
  18. throw lastErr
  19. }
  20. function debugProxyEnabled() {
  21. return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
  22. }
  23. function debugProxyAxiosFragment() {
  24. const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
  25. const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
  26. return {
  27. proxy: {
  28. host,
  29. port,
  30. protocol: 'http'
  31. }
  32. }
  33. }
  34. function briefUrlPath(fullUrl) {
  35. try {
  36. const u = new URL(fullUrl)
  37. return `${u.pathname}${u.search}`
  38. } catch {
  39. return fullUrl
  40. }
  41. }
  42. /** 与 Worker 日志对齐:先 traceId(如 1778232257819_dfpcft),再模块名,可选 MQ 任务 id */
  43. function lepaoHttpLogLabel(traceId, mqTaskId) {
  44. let s = ''
  45. if (traceId) s += `[${traceId}] `
  46. s += '[lepaoSchoolHttp]'
  47. if (mqTaskId) s += ` [${mqTaskId}]`
  48. return s
  49. }
  50. function isQgProxyEligibleFailure(err) {
  51. if (!err) return false
  52. const status = err.response?.status
  53. if (status === 407) return true
  54. if (status === 408) return true
  55. if (status === 500) return true
  56. if (status === 502 || status === 503 || status === 504) return true
  57. if (
  58. err.code &&
  59. ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPROTO', 'ERR_FR_TOO_MANY_REDIRECTS', 'ERR_INVALID_PROTOCOL'].includes(
  60. err.code
  61. )
  62. ) {
  63. return true
  64. }
  65. if (err.isAxiosError && !err.response) return true
  66. const msg = (err.message || '').toLowerCase()
  67. if (msg.includes('timeout') || msg.includes('socket') || msg.includes('network')) return true
  68. return false
  69. }
  70. function summarizeAxiosError(err) {
  71. if (!err) return {}
  72. return {
  73. message: err.message,
  74. code: err.code,
  75. status: err.response?.status,
  76. isAxiosError: err.isAxiosError
  77. }
  78. }
  79. /** CONNECT Tunnel 后与目标站 TLS 握手前被断开——换 IP 往往无效,应少打 /get、尽快直连 */
  80. function isProxyTlsHandshakeReset(err) {
  81. if (!err) return false
  82. const code = err.code
  83. if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
  84. const msg = String(err.message || '')
  85. return /tls|secure\s+tls|handshake/i.test(msg)
  86. }
  87. /**
  88. * @param {*} logger Worker logger 或 null
  89. * @param {{ skipQgSnapshot?: boolean }} opts 为 true 时仅用 axios 配置的 host:port(如 Charles)
  90. */
  91. async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
  92. if (!logger?.info) return
  93. const path = briefUrlPath(url)
  94. const label = lepaoHttpLogLabel(opts.traceId, opts.mqTaskId)
  95. if (!axiosMerge || axiosMerge.proxy === false || !axiosMerge.proxy) {
  96. logger.info(`${label} ${phase} POST 出站=直连 path=${path}`)
  97. return
  98. }
  99. const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}`
  100. if (opts.skipQgSnapshot) {
  101. logger.info(
  102. `${label} ${phase} POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
  103. )
  104. return
  105. }
  106. const snap = await QgProxyManager.getCachedParsed()
  107. const serverRecord = snap?.server ?? conn
  108. const egress = snap?.proxyIp ?? '(暂无 proxy_ip)'
  109. const dl = snap?.deadline ?? '—'
  110. logger.info(
  111. `${label} ${phase} POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
  112. )
  113. }
  114. /**
  115. * 对 lepao.ctbu.edu.cn 的 POST:优先隧道代理;失败快速直连并记日志(隧道池出口由服务商后台切换)。
  116. */
  117. async function postLepaoSchool(url, data, options = {}) {
  118. const {
  119. headers = {},
  120. timeout = 15000,
  121. logger = null,
  122. outboundMode = 'auto',
  123. mqTaskId = null,
  124. traceId = null
  125. } = options
  126. const logLabel = () => lepaoHttpLogLabel(traceId, mqTaskId)
  127. const doPost = async (qgProxyFragment, requestTimeout = timeout) => {
  128. const outbound = buildAxiosOutboundConfig(qgProxyFragment)
  129. return axios.post(url, data, {
  130. headers,
  131. timeout: requestTimeout,
  132. ...outbound
  133. })
  134. }
  135. // 强制直连:策略 A 用(任务内固定出站,禁止中途切换)
  136. if (outboundMode === 'direct') {
  137. await logSchoolOutbound(logger, '(强制直连)', url, { proxy: false }, { mqTaskId, traceId })
  138. return doPost({ proxy: false })
  139. }
  140. if (debugProxyEnabled()) {
  141. const dbg = debugProxyAxiosFragment()
  142. await logSchoolOutbound(logger, 'Charles调试代理', url, dbg, {
  143. skipQgSnapshot: true,
  144. mqTaskId,
  145. traceId
  146. })
  147. logger?.info?.(`${logLabel()} 使用本地调试代理 LEPAO_DEBUG_PROXY`)
  148. return doPost(dbg)
  149. }
  150. const qgOn = await QgProxyManager.isOutboundProxyEnabled()
  151. if (!qgOn) {
  152. await logSchoolOutbound(logger, '(青果出站未启用)', url, { proxy: false }, { mqTaskId, traceId })
  153. return doPost({ proxy: false })
  154. }
  155. let frag
  156. try {
  157. frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
  158. } catch (e0) {
  159. if (outboundMode === 'proxy') {
  160. const err = new Error(`代理模式提取失败: ${e0.message || e0}`)
  161. err.code = 'PROXY_REQUIRED_EXTRACT_FAILED'
  162. err.retryable = true
  163. throw err
  164. }
  165. logger?.error?.(`${logLabel()} 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
  166. await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false }, { mqTaskId, traceId })
  167. await QgProxyManager.recordFallbackDirect({
  168. reason: 'qg_extract_error',
  169. mq_task_id: mqTaskId,
  170. trace_id: traceId,
  171. ...summarizeAxiosError(e0)
  172. })
  173. return doPost({ proxy: false })
  174. }
  175. if (frag.proxy === false) {
  176. try {
  177. await sleep(400)
  178. frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
  179. } catch {
  180. /* 保持 frag 原状 */
  181. }
  182. }
  183. if (frag.proxy === false) {
  184. logger?.warn?.(`${logLabel()} 无可用青果节点,对学校 POST 将直连`)
  185. if (outboundMode === 'proxy') {
  186. const err = new Error('代理模式无可用节点')
  187. err.code = 'PROXY_REQUIRED_NO_NODE'
  188. err.retryable = true
  189. throw err
  190. }
  191. await logSchoolOutbound(logger, '(无缓存节点→直连)', url, { proxy: false }, { mqTaskId, traceId })
  192. await QgProxyManager.recordFallbackDirect({
  193. reason: 'no_proxy_available',
  194. mq_task_id: mqTaskId,
  195. trace_id: traceId
  196. })
  197. return doPost({ proxy: false })
  198. }
  199. await logSchoolOutbound(logger, '首次请求', url, frag, { mqTaskId, traceId })
  200. try {
  201. const proxyFirstTimeoutMs = 20000
  202. return await doPost(frag, proxyFirstTimeoutMs)
  203. } catch (e1) {
  204. if (outboundMode === 'proxy') {
  205. const err = new Error(`代理模式请求失败: ${e1.message || e1}`)
  206. err.code = 'PROXY_REQUIRED_REQUEST_FAILED'
  207. err.retryable = true
  208. throw err
  209. }
  210. if (!isQgProxyEligibleFailure(e1)) throw e1
  211. logger?.warn?.(
  212. `${logLabel()} 经代理首次请求失败,将直接回退直连。err=${e1.message || e1} ${JSON.stringify(
  213. summarizeAxiosError(e1)
  214. )}`
  215. )
  216. const tls1 = isProxyTlsHandshakeReset(e1)
  217. if (tls1) {
  218. logger?.warn?.(
  219. `${logLabel()} TLS 握手前经代理断开,隧道池模式直接直连(由服务商后台自动切换出口)`
  220. )
  221. await logSchoolOutbound(logger, '(TLS隧道异常→直连)', url, { proxy: false }, { mqTaskId, traceId })
  222. await QgProxyManager.recordFallbackDirect({
  223. reason: 'tls_prefinish_reset_direct',
  224. path: briefUrlPath(url),
  225. mq_task_id: mqTaskId,
  226. trace_id: traceId,
  227. ...summarizeAxiosError(e1)
  228. })
  229. return doPost({ proxy: false })
  230. }
  231. await logSchoolOutbound(logger, '(代理失败→直连)', url, { proxy: false }, { mqTaskId, traceId })
  232. await QgProxyManager.recordFallbackDirect({
  233. reason: 'proxy_post_failed_then_direct',
  234. path: briefUrlPath(url),
  235. mq_task_id: mqTaskId,
  236. trace_id: traceId,
  237. ...summarizeAxiosError(e1)
  238. })
  239. return doPost({ proxy: false })
  240. }
  241. }
  242. module.exports = {
  243. postLepaoSchool,
  244. isQgProxyEligibleFailure,
  245. debugProxyEnabled,
  246. debugProxyAxiosFragment
  247. }