Browse Source

✨ feat: 电费、书单走代理

Pchen0 4 hours ago
parent
commit
dea6ff07a2

+ 8 - 2
apis/Corn/StartPowerCheck.js

@@ -1,6 +1,6 @@
 const API = require("../../lib/API.js")
 const db = require('../../plugin/DataBase/db.js')
-const axios = require("axios")
+const { axiosWithQgOutbound } = require('../../lib/Lepao/qgOutboundAxios')
 const EmailTemplate = require('../../plugin/Email/emailTemplate')
 const { BaseStdResponse } = require("../../BaseStdResponse.js")
 
@@ -32,7 +32,13 @@ class StartPowerCheck extends API {
 
                 try {
                     const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
-                    const response = await axios.get(endpoint, { proxy: false, timeout: 8000 })
+                    const response = await axiosWithQgOutbound({
+                        method: 'get',
+                        url: endpoint,
+                        timeout: 15000,
+                        logger: this.logger,
+                        scene: 'CornPowerCheck'
+                    })
 
                     if (!response || !response.data || !response.data[0]) {
                         this.logger.error('获取电费信息失败!返回数据:' + (response && response.data ? JSON.stringify(response.data) : 'no-response'))

+ 10 - 6
apis/Power/AddAccount.js

@@ -1,6 +1,6 @@
 const API = require("../../lib/API.js")
 const db = require("../../plugin/DataBase/db.js")
-const axios = require("axios")
+const { axiosWithQgOutbound } = require("../../lib/Lepao/qgOutboundAxios")
 const { BaseStdResponse } = require("../../BaseStdResponse.js")
 const AccessControl = require("../../lib/AccessControl.js")
 
@@ -33,17 +33,21 @@ class AddAccount extends API {
             let balance, koufei_date
             try {
                 const endpoint = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=lastlist&pid=${room}&dyid=${building}`
-                const res = await axios.get(endpoint, {
-                    proxy: false
+                const apiRes = await axiosWithQgOutbound({
+                    method: 'get',
+                    url: endpoint,
+                    timeout: 15000,
+                    logger: this.logger,
+                    scene: 'PowerAccount'
                 })
 
-                if (!res || !res.data || !res.data[0])
+                if (!apiRes || !apiRes.data || !apiRes.data[0])
                     return res.json({
                         ...BaseStdResponse.ERR,
                         msg: '获取电费信息失败!请稍后再试'
                     })
-                balance = res.data[0][1]
-                koufei_date = res.data[0][2]
+                balance = apiRes.data[0][1]
+                koufei_date = apiRes.data[0][2]
 
             } catch (error) {
                 this.logger.error(`获取电费信息失败!${error.stack}`)

+ 7 - 3
apis/Power/GetPowerData.js

@@ -1,5 +1,5 @@
 const API = require("../../lib/API.js")
-const axios = require("axios")
+const { axiosWithQgOutbound } = require("../../lib/Lepao/qgOutboundAxios")
 const { BaseStdResponse } = require("../../BaseStdResponse.js")
 
 // 获取楼栋
@@ -25,8 +25,12 @@ class GetDyList extends API {
                     })
 
             const url = `https://hqpay.ctbu.edu.cn/weixin/ashx/frmuser.ashx?test=${type}${type !== 'buildlist' ? '&pid=' + pid : ''}`
-            const response = await axios.get(url, {
-                proxy: false
+            const response = await axiosWithQgOutbound({
+                method: 'get',
+                url,
+                timeout: 15000,
+                logger: this.logger,
+                scene: 'PowerGetData'
             })
             if (!response || !response.data)
                 return res.json({

+ 40 - 16
apis/QuXuanShu/GetBookList.js

@@ -1,6 +1,6 @@
 const API = require("../../lib/API.js")
 const db = require("../../plugin/DataBase/db.js")
-const axios = require("axios")
+const { axiosWithQgOutbound } = require("../../lib/Lepao/qgOutboundAxios")
 const OSS = require("ali-oss")
 const { BaseStdResponse } = require("../../BaseStdResponse.js")
 
@@ -25,12 +25,17 @@ class GetBookList extends API {
             multiUserType: "0,1,2,3,4,5"
         }
 
-        const res = await axios.post(endpoint, reqData, {
-            proxy: false,
+        const res = await axiosWithQgOutbound({
+            method: 'post',
+            url: endpoint,
+            data: reqData,
             headers: {
                 "User-Agent": this.UserAgent,
                 "Referer": this.Refer
-            }
+            },
+            timeout: 15000,
+            logger: this.logger,
+            scene: 'QXSLogin'
         })
 
         const data = res.data
@@ -42,13 +47,17 @@ class GetBookList extends API {
 
     async qsxUserInfo(accessToken) {
         const endpoint = "https://api.quxuanshu.com/pass/loginInfo"
-        const res = await axios.get(endpoint, {
-            proxy: false,
+        const res = await axiosWithQgOutbound({
+            method: 'get',
+            url: endpoint,
             headers: {
                 accessToken,
                 "User-Agent": this.UserAgent,
                 "Referer": this.Refer,
-            }
+            },
+            timeout: 15000,
+            logger: this.logger,
+            scene: 'QXSUserInfo'
         })
         const data = res.data
         if (!data || data.code !== 0 || !data.data) {
@@ -59,13 +68,18 @@ class GetBookList extends API {
 
     async qsxGetList(accessToken, termCode) {
         const endpoint = "https://api.quxuanshu.com/student/order/toOrder/list"
-        const res = await axios.post(endpoint, { termCode }, {
-            proxy: false,
+        const res = await axiosWithQgOutbound({
+            method: 'post',
+            url: endpoint,
+            data: { termCode },
             headers: {
                 accessToken,
                 "User-Agent": this.UserAgent,
                 "Referer": this.Refer
-            }
+            },
+            timeout: 20000,
+            logger: this.logger,
+            scene: 'QXSOrderList'
         })
         const data = res.data
         if (!data || data.code !== 0 || !data.data) {
@@ -75,13 +89,18 @@ class GetBookList extends API {
 
             if (data.msg === '统一订购模式下无权限查看订购数据!') {
                 const endpoint = "https://api.quxuanshu.com/student/order/orderInfo/list"
-                const res = await axios.post(endpoint, { type: 0, termCode }, {
-                    proxy: false,
+                const res = await axiosWithQgOutbound({
+                    method: 'post',
+                    url: endpoint,
+                    data: { type: 0, termCode },
                     headers: {
                         accessToken,
                         "User-Agent": this.UserAgent,
                         "Referer": this.Refer
-                    }
+                    },
+                    timeout: 15000,
+                    logger: this.logger,
+                    scene: 'QXSOrderInfoList'
                 })
                 const data = res.data
                 if (!data || data.code !== 0 || !data.data || !data.data.list[0] || !data.data.list[0].orderItem) {
@@ -107,13 +126,18 @@ class GetBookList extends API {
             checkCode: "",
             awardTypeList: []
         }
-        const res = await axios.post(endpoint, queryData, {
-            proxy: false,
+        const res = await axiosWithQgOutbound({
+            method: 'post',
+            url: endpoint,
+            data: queryData,
             headers: {
                 "AccessToken": accessToken,
                 "User-Agent": this.UserAgent,
                 "Referer": this.Refer
-            }
+            },
+            timeout: 15000,
+            logger: this.logger,
+            scene: 'QXSTextbookSearch'
         })
         let data
         if (!res.data || !res.data.data || !res.data.data.list)

+ 276 - 0
lib/Lepao/qgOutboundAxios.js

@@ -0,0 +1,276 @@
+/**
+ * 与 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
+}