| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- /**
- * 与 lepaoSchoolHttp 一致的青果 HTTP 出站:HttpsProxyAgent + 先代理(短超时)再可回退直连。
- * 供电费、选课书单等非 Worker 场景的对外 GET/POST 使用。
- */
- const axios = require('axios')
- const HttpsProxyAgent = require('https-proxy-agent')
- const QgProxyManager = require('./QgProxyManager')
- const PROXY_FIRST_TIMEOUT_MS = 20000
- function sleep(ms) {
- return new Promise(r => setTimeout(r, ms))
- }
- async function getOutboundWithBackoff(qgOpts, rounds = 2) {
- let lastErr
- for (let i = 0; i < rounds; i++) {
- try {
- if (i > 0) await sleep(380 * i * i)
- return await QgProxyManager.getOutboundAxiosFragment(qgOpts)
- } catch (e) {
- lastErr = e
- }
- }
- throw lastErr
- }
- function buildAxiosOutboundConfig(fragment) {
- if (!fragment || fragment.proxy === false || !fragment.proxy) {
- return { proxy: false }
- }
- const { host, port, auth } = fragment.proxy
- let userPart = ''
- if (auth && String(auth.username || '').length > 0) {
- const u = encodeURIComponent(auth.username)
- const p = encodeURIComponent(auth.password != null ? String(auth.password) : '')
- userPart = `${u}:${p}@`
- }
- const proxyUrl = `http://${userPart}${host}:${port}`
- const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
- return {
- proxy: false,
- httpsAgent: new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
- }
- }
- function debugProxyEnabled() {
- return String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
- }
- function debugProxyAxiosFragment() {
- const host = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
- const port = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
- return {
- proxy: {
- host,
- port,
- protocol: 'http'
- }
- }
- }
- function briefUrlPath(fullUrl) {
- try {
- const u = new URL(fullUrl)
- return `${u.pathname}${u.search}`
- } catch {
- return fullUrl
- }
- }
- function isQgProxyEligibleFailure(err) {
- if (!err) return false
- const status = err.response?.status
- if (status === 407 || status === 408 || status === 500) return true
- if (status === 502 || status === 503 || status === 504) return true
- if (
- err.code &&
- ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPROTO', 'ERR_FR_TOO_MANY_REDIRECTS'].includes(
- err.code
- )
- ) {
- return true
- }
- if (err.isAxiosError && !err.response) return true
- const msg = (err.message || '').toLowerCase()
- if (msg.includes('timeout') || msg.includes('socket') || msg.includes('network')) return true
- return false
- }
- function summarizeAxiosError(err) {
- if (!err) return {}
- return {
- message: err.message,
- code: err.code,
- status: err.response?.status,
- isAxiosError: err.isAxiosError
- }
- }
- function isProxyTlsHandshakeReset(err) {
- if (!err) return false
- const code = err.code
- if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
- const msg = String(err.message || '')
- return /tls|secure\s+tls|handshake/i.test(msg)
- }
- function logLabel(traceId, mqTaskId) {
- let s = ''
- if (traceId) s += `[${traceId}] `
- s += '[qgOutboundAxios]'
- if (mqTaskId) s += ` [${mqTaskId}]`
- return s
- }
- /**
- * @param {{
- * method?: 'get'|'post'
- * url: string
- * data?: any
- * headers?: object
- * timeout?: number
- * outboundMode?: 'auto'|'direct'|'proxy'
- * logger?: { info?: Function, warn?: Function, error?: Function }
- * traceId?: string|null
- * mqTaskId?: string|null
- * scene?: string
- * validateStatus?: (status:number)=>boolean
- * }} opts
- */
- async function axiosWithQgOutbound(opts) {
- const {
- method = 'get',
- url,
- data,
- headers = {},
- timeout = 15000,
- outboundMode = 'auto',
- logger = null,
- traceId = null,
- mqTaskId = null,
- scene = 'outbound',
- validateStatus
- } = opts
- const m = String(method).toLowerCase()
- const lbl = () => logLabel(traceId, mqTaskId)
- const baseAxiosOpts = {
- headers,
- timeout,
- proxy: false,
- ...(validateStatus ? { validateStatus } : {})
- }
- const exec = (fragment, requestTimeout) => {
- const outbound = buildAxiosOutboundConfig(fragment)
- const merged = { ...baseAxiosOpts, ...outbound, timeout: requestTimeout }
- if (m === 'post') {
- return axios.post(url, data, merged)
- }
- return axios.get(url, merged)
- }
- if (outboundMode === 'direct') {
- logger?.info?.(`${lbl()} (${scene}) 强制直连 ${m.toUpperCase()} ${briefUrlPath(url)}`)
- return exec({ proxy: false }, timeout)
- }
- if (debugProxyEnabled()) {
- const dbg = debugProxyAxiosFragment()
- logger?.info?.(`${lbl()} (${scene}) Charles 调试代理 LEPAO_DEBUG_PROXY ${briefUrlPath(url)}`)
- return exec(dbg, timeout)
- }
- const qgOn = await QgProxyManager.isOutboundProxyEnabled()
- if (!qgOn) {
- logger?.info?.(`${lbl()} (${scene}) 青果未启用 直连 ${briefUrlPath(url)}`)
- return exec({ proxy: false }, timeout)
- }
- let frag
- try {
- frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
- } catch (e0) {
- if (outboundMode === 'proxy') {
- const err = new Error(`代理模式提取失败: ${e0.message || e0}`)
- err.code = 'PROXY_REQUIRED_EXTRACT_FAILED'
- err.retryable = true
- throw err
- }
- logger?.error?.(`${lbl()} (${scene}) 青果提取失败改直连: ${e0.message || e0}`)
- await QgProxyManager.recordFallbackDirect({
- reason: 'qg_extract_error',
- path: briefUrlPath(url),
- scene,
- mq_task_id: mqTaskId,
- trace_id: traceId,
- ...summarizeAxiosError(e0)
- })
- return exec({ proxy: false }, timeout)
- }
- if (frag.proxy === false) {
- try {
- await sleep(400)
- frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
- } catch {
- /* keep */
- }
- }
- if (frag.proxy === false) {
- if (outboundMode === 'proxy') {
- const err = new Error('代理模式无可用节点')
- err.code = 'PROXY_REQUIRED_NO_NODE'
- err.retryable = true
- throw err
- }
- logger?.warn?.(`${lbl()} (${scene}) 无可用节点 直连 ${briefUrlPath(url)}`)
- await QgProxyManager.recordFallbackDirect({
- reason: 'no_proxy_available',
- path: briefUrlPath(url),
- scene,
- mq_task_id: mqTaskId,
- trace_id: traceId
- })
- return exec({ proxy: false }, timeout)
- }
- logger?.info?.(`${lbl()} (${scene}) 经代理 ${m.toUpperCase()} ${briefUrlPath(url)}`)
- try {
- return await exec(frag, PROXY_FIRST_TIMEOUT_MS)
- } catch (e1) {
- if (outboundMode === 'proxy') {
- const err = new Error(`代理模式请求失败: ${e1.message || e1}`)
- err.code = 'PROXY_REQUIRED_REQUEST_FAILED'
- err.retryable = true
- throw err
- }
- if (!isQgProxyEligibleFailure(e1)) throw e1
- logger?.warn?.(
- `${lbl()} (${scene}) 代理失败回退直连 err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
- )
- if (isProxyTlsHandshakeReset(e1)) {
- await QgProxyManager.recordFallbackDirect({
- reason: 'tls_prefinish_reset_direct',
- path: briefUrlPath(url),
- scene,
- mq_task_id: mqTaskId,
- trace_id: traceId,
- ...summarizeAxiosError(e1)
- })
- return exec({ proxy: false }, timeout)
- }
- await QgProxyManager.recordFallbackDirect({
- reason: 'proxy_post_failed_then_direct',
- path: briefUrlPath(url),
- scene,
- mq_task_id: mqTaskId,
- trace_id: traceId,
- ...summarizeAxiosError(e1)
- })
- return exec({ proxy: false }, timeout)
- }
- }
- module.exports = {
- axiosWithQgOutbound,
- buildAxiosOutboundConfig,
- getOutboundWithBackoff
- }
|