Browse Source

修复代理相关bug

Pchen0 6 hours ago
parent
commit
e0c85de7d7
3 changed files with 307 additions and 7 deletions
  1. 22 5
      lib/Lepao/lepaoSchoolHttp.js
  2. 6 2
      scripts/lepao-proxy-tls-test.js
  3. 279 0
      scripts/lepao-real-proxy-test.js

+ 22 - 5
lib/Lepao/lepaoSchoolHttp.js

@@ -201,8 +201,25 @@ async function postLepaoSchool(url, data, options = {}) {
         await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
 
         const tls1 = isProxyTlsHandshakeReset(e1)
-        // TLS 握手前断连时通道往往系统性不可用,短时退避即可,避免与其它任务争抢 /get
-        await sleep(tls1 ? 400 : 800)
+        /**
+         * TLS 握手前即 ECONNRESET:换出口 IP 往往仍失败;此时立即再 forceRefresh /get 容易与并发任务
+         * 抢单通道配额,大面积 NO_AVAILABLE_CHANNEL。线上「脚本测主页 GET OK、接口 POST 仍失败」也属同类。
+         * 作废缓存后直接直连,省配额且与用户最终成功路径一致。
+         */
+        if (tls1) {
+            logger?.warn?.(
+                '[lepaoSchoolHttp] TLS 握手前经代理断开,跳过二次青果提取,直接直连以避免 NO_AVAILABLE_CHANNEL 与无谓重试'
+            )
+            await logSchoolOutbound(logger, '(TLS隧道异常→跳过换新→直连)', url, { proxy: false })
+            await QgProxyManager.recordFallbackDirect({
+                reason: 'tls_prefinish_reset_direct_skip_qg_refresh',
+                path: briefUrlPath(url),
+                ...summarizeAxiosError(e1)
+            })
+            return doPost({ proxy: false })
+        }
+
+        await sleep(800)
 
         let frag2
         try {
@@ -219,7 +236,7 @@ async function postLepaoSchool(url, data, options = {}) {
 
         if (frag2.proxy === false) {
             await logSchoolOutbound(logger, '(换新无节点→直连)', url, { proxy: false })
-            await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: tls1 })
+            await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_after_invalidate', tls_tunnel: false })
             return doPost({ proxy: false })
         }
 
@@ -235,11 +252,11 @@ async function postLepaoSchool(url, data, options = {}) {
                 )}`
             )
             await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
-            await logSchoolOutbound(logger, tls1 && tls2 ? '(连续TLS隧道失败→直连)' : '(二次代理失败→直连)', url, {
+            await logSchoolOutbound(logger, tls2 ? '(二次仍TLS隧道失败→直连)' : '(二次代理失败→直连)', url, {
                 proxy: false
             })
             await QgProxyManager.recordFallbackDirect({
-                reason: tls1 && tls2 ? 'tls_tunnel_broken_twice' : 'proxy_post_retry_failed',
+                reason: tls2 ? 'tls_tunnel_broken_twice' : 'proxy_post_retry_failed',
                 ...summarizeAxiosError(e2)
             })
             return doPost({ proxy: false })

+ 6 - 2
scripts/lepao-proxy-tls-test.js

@@ -9,9 +9,13 @@
  *   node scripts/lepao-proxy-tls-test.js proxy 103.217.191.35:20028
  *   node scripts/lepao-proxy-tls-test.js qg-fetch       # 用 config.json 调青果 /get 取 1 个节点再测
  *
+ * 环境与线上一致时务必对齐「真实 API + POST」(仅测主页 GET 通过不代表 beforeRun 等接口经代理可用):
+ *   TEST_HTTPS_URL=https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260 TEST_POST=1 \\
+ *     node scripts/lepao-proxy-tls-test.js qg-fetch
+ *
  * 环境变量(可选):
- *   TEST_HTTPS_URL   默认 https://lepao.ctbu.edu.cn/(也可用具体 API,如 …/WpIndex/getOssSts)
- *   TEST_POST        若为 1,则对 TEST_HTTPS_URL 发 POST(body 为空 JSON),更接近业务
+ *   TEST_HTTPS_URL   默认 https://lepao.ctbu.edu.cn/
+ *   TEST_POST        若为 1,则 POST,body 为 {}
  *   TEST_TIMEOUT_MS  默认 20000
  *   QG_PROXY_USER / QG_PROXY_PASSWORD  覆盖 config 里 authUser/authPassword
  *   QG_AREA / QG_AREA_EX / QG_ISP / QG_DISTINCT  qg-fetch 时传给青果(与线上一致可用来复现)

+ 279 - 0
scripts/lepao-real-proxy-test.js

@@ -0,0 +1,279 @@
+#!/usr/bin/env node
+'use strict'
+
+/**
+ * 「加密表单 + postLepaoSchool(青果代理/回退)」探测乐跑 HTTPS 链路;报文格式与线上相同。
+ *
+ * 默认不写库不传 token:内置伪造 uid/token/学号,仅用于测 CONNECT/TLS/代理;业务上会返回登录失效属正常。
+ * 也可用环境变量覆盖伪造字段(见 loadFakeAccount)。
+ *
+ * 用法(项目根):
+ *
+ *    node scripts/lepao-real-proxy-test.js
+ *    node scripts/lepao-real-proxy-test.js --api getOssSts
+ *
+ * 真实账号(可选):
+ *    node scripts/lepao-real-proxy-test.js --student-num 2025402496
+ *    node scripts/lepao-real-proxy-test.js --student-num 2025402496 --create-user xxx
+ *    LEPAO_TEST_UID=… LEPAO_TEST_TOKEN=… … node scripts/lepao-real-proxy-test.js --env
+ *
+ *    --timeout 20000          Charles: LEPAO_DEBUG_PROXY=1
+ */
+
+const { URLSearchParams } = require('url')
+
+const db = require('../plugin/DataBase/db')
+const Redis = require('../plugin/DataBase/Redis')
+const { dataEncrypt, dataDecrypt, dataSign } = require('../plugin/Lepao/Crypto')
+const { postLepaoSchool } = require('../lib/Lepao/lepaoSchoolHttp')
+
+const DEFAULT_UA =
+    'Mozilla/5.0 (Linux; Android 16; 2211133C Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36'
+
+const BASE = 'https://lepao.ctbu.edu.cn'
+
+const ENDPOINTS = {
+    beforeRun: {
+        path: '/v3/api.php/Run2/beforeRunV260',
+        buildRaw: ({ uid, token, school_id, student_num }) => ({
+            uid: Number(uid),
+            token,
+            school_id: Number(school_id),
+            term_id: 0,
+            course_id: 0,
+            class_id: 0,
+            student_num,
+            card_id: student_num,
+            timestamp: Number((Date.now() / 1000).toFixed(3)),
+            version: 1,
+            nonce: String(Math.floor(Math.random() * 900000 + 100000)),
+            ostype: 5
+        })
+    },
+    getOssSts: {
+        path: '/v3/api.php/WpIndex/getOssSts',
+        buildRaw: ({ uid, token, school_id, student_num }) => ({
+            uid: Number(uid),
+            token,
+            school_id: Number(school_id),
+            term_id: 0,
+            course_id: 0,
+            class_id: 0,
+            student_num,
+            card_id: student_num,
+            timestamp: Number((Date.now() / 1000).toFixed(3)),
+            version: 1,
+            nonce: String(Math.floor(Math.random() * 900000 + 100000)),
+            ostype: 5
+        })
+    }
+}
+
+const logger = {
+    info(...a) {
+        console.log('[INFO]', new Date().toISOString(), ...a)
+    },
+    warn(...a) {
+        console.warn('[WARN]', new Date().toISOString(), ...a)
+    },
+    error(...a) {
+        console.error('[ERROR]', new Date().toISOString(), ...a)
+    }
+}
+
+function parseArgs(argv) {
+    const out = { help: false, api: 'beforeRun', studentNum: null, createUser: null, useEnv: false, timeout: 20000 }
+    for (let i = 2; i < argv.length; i++) {
+        const a = argv[i]
+        if (a === '--help' || a === '-h') out.help = true
+        else if (a === '--env') out.useEnv = true
+        else if (a === '--student-num' && argv[i + 1]) out.studentNum = argv[++i]
+        else if (a === '--create-user' && argv[i + 1]) out.createUser = argv[++i]
+        else if (a.startsWith('--api=')) out.api = a.slice('--api='.length)
+        else if (a === '--api' && argv[i + 1]) out.api = argv[++i]
+        else if (a === '--timeout' && argv[i + 1]) out.timeout = Number(argv[++i])
+        else if (!a.startsWith('-') && !out.studentNum) out.studentNum = a
+    }
+    return out
+}
+
+async function loadAccountFromDb(studentNum, createUser) {
+    const conditionSql = createUser ? 'student_num = ? AND create_user = ?' : 'student_num = ?'
+    const queryParams = createUser ? [studentNum, createUser] : [studentNum]
+    const rows = await db.query(
+        `SELECT uid, token, school_id, userAgent FROM lepao_account WHERE ${conditionSql} LIMIT 5`,
+        queryParams
+    )
+    if (!rows || rows.length === 0) {
+        throw new Error('数据库中未找到该学号对应的 lepao_account')
+    }
+    if (rows.length > 1 && !createUser) {
+        throw new Error(`学号 ${studentNum} 存在多条记录,请附加 --create-user 指定其一`)
+    }
+    return { ...rows[0], student_num: studentNum }
+}
+
+function loadAccountFromEnv() {
+    const uid = Number(process.env.LEPAO_TEST_UID)
+    const token = String(process.env.LEPAO_TEST_TOKEN || '').trim()
+    const school_id = Number(process.env.LEPAO_TEST_SCHOOL_ID)
+    const student_num = String(process.env.LEPAO_TEST_STUDENT_NUM || '').trim()
+    const userAgent = process.env.LEPAO_TEST_USER_AGENT?.trim()
+
+    if (!uid || !token || !school_id || !student_num) {
+        throw new Error(
+            '请设置 LEPAO_TEST_UID LEPAO_TEST_TOKEN LEPAO_TEST_SCHOOL_ID LEPAO_TEST_STUDENT_NUM(可选 LEPAO_TEST_USER_AGENT)'
+        )
+    }
+    return {
+        uid,
+        token,
+        school_id,
+        student_num,
+        userAgent: userAgent || DEFAULT_UA
+    }
+}
+
+function numberFromEnv(key, fallback) {
+    const v = process.env[key]
+    if (v === undefined || v === '') return fallback
+    const n = Number(v)
+    return Number.isFinite(n) ? n : fallback
+}
+
+/** 伪造字段,仅校验加密/签名封装与出站链路;学校端通常返回登录失效属预期 */
+function loadFakeAccount() {
+    const uid = numberFromEnv('LEPAO_FAKE_UID', 1009001)
+    let token = String(process.env.LEPAO_FAKE_TOKEN || '').trim()
+    if (!token) token = 'FACEFACEFACEFACEFACEFACEFACEFACE'
+    const school_id = numberFromEnv('LEPAO_FAKE_SCHOOL_ID', 201)
+    let student_num = String(process.env.LEPAO_FAKE_STUDENT_NUM || '').trim()
+    if (!student_num) student_num = '2099123456'
+    const userAgent = String(process.env.LEPAO_FAKE_UA || process.env.LEPAO_TEST_USER_AGENT || DEFAULT_UA).trim()
+
+    const account = { uid, token, school_id, student_num, userAgent }
+    logger.info('[fake payload]', { uid: account.uid, school_id: account.school_id, student_num: account.student_num })
+    return account
+}
+
+function buildEncryptedForm(raw) {
+    raw.sign = dataSign(raw)
+    const form = new URLSearchParams()
+    form.append('ostype', '5')
+    const enc = dataEncrypt(JSON.stringify(raw))
+    if (!enc) throw new Error('dataEncrypt 失败')
+    form.append('data', enc)
+    return form
+}
+
+function printSummary(body) {
+    const result = typeof body === 'object' && body !== null ? body : null
+    if (!result) {
+        console.log('响应:', String(body).slice(0, 500))
+        return
+    }
+    console.log('status 字段:', result.status, 'info/msg:', result.info || result.msg)
+    let dataOut = result.data
+    if (result.is_encrypt === 1 && typeof dataOut === 'string') {
+        const dec = dataDecrypt(dataOut)
+        try {
+            dataOut = JSON.parse(dec)
+        } catch {
+            dataOut = dec
+        }
+        console.log('data(已解密):', JSON.stringify(dataOut, null, 2).slice(0, 4000))
+    } else {
+        console.log('data:', JSON.stringify(dataOut, null, 2).slice(0, 4000))
+    }
+}
+
+async function shutdownConnections() {
+    await db.close().catch(() => {})
+    try {
+        if (Redis.isOpen) {
+            await Redis.quit()
+        }
+    } catch (_) {
+        /* ignore */
+    }
+}
+
+const HELP_TEXT = `lepao-real-proxy-test.js
+  默认使用伪造账号(不写库),用于测代理/TLS/加密报文。
+    node scripts/lepao-real-proxy-test.js
+    node scripts/lepao-real-proxy-test.js --api getOssSts
+  伪造字段可用环境变量覆盖: LEPAO_FAKE_UID LEPAO_FAKE_TOKEN LEPAO_FAKE_SCHOOL_ID LEPAO_FAKE_STUDENT_NUM LEPAO_FAKE_UA
+  真实账号: --student-num <学号> [--create-user …] 或 --env + LEPAO_TEST_*
+  其它: --timeout <ms>   LEPAO_DEBUG_PROXY=1`
+
+async function main() {
+    try {
+        const args = parseArgs(process.argv)
+        if (args.help) {
+            console.log(HELP_TEXT)
+            throw new Error('HELP')
+        }
+
+        const ep = ENDPOINTS[args.api]
+        if (!ep) {
+            console.error(`未知 --api=${args.api},可选 beforeRun | getOssSts`)
+            throw new Error('BAD_API')
+        }
+
+        let account
+        if (args.useEnv) {
+            account = loadAccountFromEnv()
+        } else if (args.studentNum) {
+            account = await loadAccountFromDb(args.studentNum, args.createUser)
+        } else {
+            account = loadFakeAccount()
+        }
+
+        const raw = ep.buildRaw({
+            uid: account.uid,
+            token: account.token,
+            school_id: account.school_id,
+            student_num: String(account.student_num)
+        })
+        const form = buildEncryptedForm(raw)
+
+        const headers = {
+            'Content-Type': 'application/x-www-form-urlencoded',
+            Accept: '*/*',
+            'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
+            Referer: 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
+            'User-Agent': account.userAgent || DEFAULT_UA
+        }
+
+        const fullUrl = BASE + ep.path
+        const dataTag = args.useEnv ? '[env]' : args.studentNum ? '[db]' : '[fake]'
+        logger.info('接口:', args.api, fullUrl, dataTag, '学号:', String(account.student_num))
+
+        const apiRes = await postLepaoSchool(fullUrl, form, {
+            headers,
+            timeout: args.timeout,
+            logger
+        })
+
+        logger.info('axios HTTP 状态:', apiRes.status)
+        if (!args.studentNum && !args.useEnv) {
+            logger.info('提示: 伪造 token 时业务 status 非 1、提示重新登录等属于预期,关注是否出现 ECONNRESET/TLS 类错误')
+        }
+        printSummary(apiRes.data)
+    } finally {
+        await shutdownConnections()
+    }
+}
+
+main()
+    .then(() => process.exit(0))
+    .catch(err => {
+        const m = String(err.message)
+        if (m === 'HELP') {
+            process.exit(0)
+        }
+        if (m !== 'BAD_API') {
+            console.error(err.stack || err)
+        }
+        process.exit(1)
+    })