Browse Source

✨ feat: 新增青果ip代理

Pchen0 2 hours ago
parent
commit
c575698ae7

+ 82 - 0
apis/Lepao/Proxy/Admin/Config.js

@@ -0,0 +1,82 @@
+const API = require('../../../../lib/API.js')
+const { BaseStdResponse } = require('../../../../BaseStdResponse.js')
+const AccessControl = require('../../../../lib/AccessControl.js')
+const db = require('../../../../plugin/DataBase/db.js')
+const Redis = require('../../../../plugin/DataBase/Redis.js')
+const QgProxyManager = require('../../../../lib/Lepao/QgProxyManager')
+
+class AdminLepaoProxyConfig extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/Proxy/Config')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const {
+            uuid,
+            session,
+            proxy_enabled,
+            area,
+            area_ex,
+            isp,
+            distinct_extract,
+            invalidate_cache
+        } = req.body
+
+        if ([uuid, session].some(v => v === '' || v == null) || proxy_enabled === undefined || proxy_enabled === null)
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const enabled =
+            proxy_enabled === true || proxy_enabled === 1 || proxy_enabled === '1' ? 1 : 0
+        const areaStr = area == null ? '' : String(area).trim()
+        const areaExStr = area_ex == null ? '' : String(area_ex).trim()
+        let ispVal = null
+        if (isp !== '' && isp !== undefined && isp !== null) {
+            const n = Number(isp)
+            if (n === 1 || n === 2 || n === 3) ispVal = n
+        }
+        const distinct = Number(distinct_extract) === 0 ? 0 : 1
+        const now = Date.now()
+
+        try {
+            await QgProxyManager.ensureSettingsRow()
+            await db.query(
+                `UPDATE lepao_proxy_settings SET proxy_enabled = ?, area = ?, area_ex = ?, isp = ?, distinct_extract = ?, updated_at = ? WHERE id = 1`,
+                [enabled, areaStr, areaExStr, ispVal, distinct, now]
+            )
+
+            if (invalidate_cache === true || invalidate_cache === 1 || invalidate_cache === '1') {
+                await Redis.del(QgProxyManager.REDIS_CURRENT)
+            }
+
+            await QgProxyManager.recordLog({
+                event: 'config_change',
+                detail: {
+                    proxy_enabled: enabled,
+                    area: areaStr,
+                    area_ex: areaExStr,
+                    isp: ispVal,
+                    distinct_extract: distinct,
+                    invalidate_cache: !!invalidate_cache,
+                    operator: uuid
+                }
+            })
+
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (e) {
+            this.logger?.error?.(`AdminLepaoProxyConfig: ${e.stack || e}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '保存配置失败' })
+        }
+    }
+}
+
+module.exports.AdminLepaoProxyConfig = AdminLepaoProxyConfig

+ 84 - 0
apis/Lepao/Proxy/Admin/Logs.js

@@ -0,0 +1,84 @@
+const API = require('../../../../lib/API.js')
+const { BaseStdResponse } = require('../../../../BaseStdResponse.js')
+const AccessControl = require('../../../../lib/AccessControl.js')
+const db = require('../../../../plugin/DataBase/db.js')
+const { summarizeLogRow, extractEgressIp } = require('../../../../lib/Lepao/lepaoProxyLogDisplay')
+const { lookupIpv4Region } = require('../../../../lib/Lepao/ipRegionLookup')
+
+class AdminLepaoProxyLogs extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/Proxy/Logs')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, pagesize, current } = req.query
+
+        if ([uuid, session, pagesize, current].some(v => v === '' || v == null))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || isNaN(current) || Number(current) <= 0)
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        const lim = Math.min(200, Math.max(1, Math.floor(Number(pagesize))))
+        const off = Math.max(0, (Math.floor(Number(current)) - 1) * lim)
+        try {
+            const countRows = await db.query(`SELECT COUNT(*) AS total FROM lepao_proxy_log`)
+            const rows = await db.query(
+                `SELECT id, created_at, event, server, deadline, detail FROM lepao_proxy_log ORDER BY id DESC LIMIT ${lim} OFFSET ${off}`
+            )
+            const total = Number(countRows?.[0]?.total || 0)
+
+            const regionCache = {}
+            async function regionForEgress(ip) {
+                if (!ip) return null
+                if (!regionCache[ip]) regionCache[ip] = await lookupIpv4Region(ip)
+                return regionCache[ip]
+            }
+
+            const data = []
+            for (const r of rows || []) {
+                const display = summarizeLogRow(r)
+                const egressIp = extractEgressIp(r)
+                const egressRegion = await regionForEgress(egressIp)
+                data.push({
+                    id: r.id,
+                    created_at: r.created_at,
+                    event: r.event,
+                    server: r.server,
+                    deadline: r.deadline,
+                    egress_ip: egressIp,
+                    egress_region: egressRegion,
+                    event_label: display.event_label,
+                    event_color: display.event_color,
+                    summary: display.summary,
+                    detail_lines: display.detail_lines
+                })
+            }
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data,
+                pagination: {
+                    current: Number(current),
+                    pagesize: lim,
+                    total
+                }
+            })
+        } catch (e) {
+            this.logger?.error?.(`AdminLepaoProxyLogs: ${e.stack || e}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '查询日志失败' })
+        }
+    }
+}
+
+module.exports.AdminLepaoProxyLogs = AdminLepaoProxyLogs

+ 74 - 0
apis/Lepao/Proxy/Admin/LogsDelete.js

@@ -0,0 +1,74 @@
+const API = require('../../../../lib/API.js')
+const { BaseStdResponse } = require('../../../../BaseStdResponse.js')
+const AccessControl = require('../../../../lib/AccessControl.js')
+const db = require('../../../../plugin/DataBase/db.js')
+
+const MAX_IDS = 300
+
+/**
+ * POST /Admin/Lepao/Proxy/Logs/Delete
+ * ids: number[] — 批量按主键删除
+ * purge_all: 1/true — 清空整张日志表(危险操作)
+ */
+class AdminLepaoProxyLogsDelete extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/Proxy/Logs/Delete')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, ids, purge_all } = req.body
+
+        if ([uuid, session].some((v) => v === '' || v == null)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('service')) {
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+        }
+
+        const purge = purge_all === true || purge_all === 1 || purge_all === '1'
+
+        try {
+            if (purge) {
+                await db.query('DELETE FROM lepao_proxy_log')
+                return res.json({
+                    ...BaseStdResponse.OK,
+                    data: { mode: 'purge_all' }
+                })
+            }
+
+            if (!Array.isArray(ids) || ids.length === 0) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '请传入 ids 数组或勾选 purge_all' })
+            }
+
+            const clean = [...new Set(ids.map((id) => Math.floor(Number(id))).filter((n) => Number.isFinite(n) && n > 0))]
+            if (clean.length === 0) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '无效的 id' })
+            }
+            if (clean.length > MAX_IDS) {
+                return res.json({ ...BaseStdResponse.ERR, msg: `单次最多删除 ${MAX_IDS} 条` })
+            }
+
+            const ph = clean.map(() => '?').join(',')
+            await db.query(`DELETE FROM lepao_proxy_log WHERE id IN (${ph})`, clean)
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: { deleted: clean.length }
+            })
+        } catch (e) {
+            this.logger?.error?.(`AdminLepaoProxyLogsDelete: ${e.stack || e}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '删除日志失败' })
+        }
+    }
+}
+
+module.exports.AdminLepaoProxyLogsDelete = AdminLepaoProxyLogsDelete

+ 47 - 0
apis/Lepao/Proxy/Admin/Resources.js

@@ -0,0 +1,47 @@
+const API = require('../../../../lib/API.js')
+const { BaseStdResponse } = require('../../../../BaseStdResponse.js')
+const AccessControl = require('../../../../lib/AccessControl.js')
+const QgProxyManager = require('../../../../lib/Lepao/QgProxyManager')
+
+/**
+ * 青果通道提取 [查询资源地区](https://www.qg.net/doc/1850.html)(GET /resources)
+ */
+class AdminLepaoProxyResources extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/Proxy/Resources')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query
+
+        if ([uuid, session].some(v => v === '' || v == null))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        if (!QgProxyManager.hasExtractCredentials()) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '未在后端 config 配置 qgChannelProxy.extractKey,无法查询资源' })
+        }
+
+        try {
+            const list = await QgProxyManager.fetchResourceAreas()
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: list || []
+            })
+        } catch (e) {
+            this.logger?.error?.(`AdminLepaoProxyResources: ${e.stack || e}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: e.message || '查询青果资源失败' })
+        }
+    }
+}
+
+module.exports.AdminLepaoProxyResources = AdminLepaoProxyResources

+ 86 - 0
apis/Lepao/Proxy/Admin/Status.js

@@ -0,0 +1,86 @@
+const API = require('../../../../lib/API.js')
+const { BaseStdResponse } = require('../../../../BaseStdResponse.js')
+const AccessControl = require('../../../../lib/AccessControl.js')
+const QgProxyManager = require('../../../../lib/Lepao/QgProxyManager')
+const { lookupIpv4Region, extractIpFromServer } = require('../../../../lib/Lepao/ipRegionLookup')
+const { parseDetail } = require('../../../../lib/Lepao/lepaoProxyLogDisplay')
+
+class AdminLepaoProxyStatus extends API {
+    constructor() {
+        super()
+
+        this.setPath('/Admin/Lepao/Proxy/Status')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query
+
+        if ([uuid, session].some(v => v === '' || v == null))
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('service'))
+            return res.json({ ...BaseStdResponse.PERMISSION_DENIED })
+
+        try {
+            const snap = await QgProxyManager.getStatusSnapshot()
+            const redisEntry = snap.redis_current || null
+            const valid = redisEntry ? QgProxyManager.cacheStillValid(redisEntry) : false
+
+            let nodeRegion = null
+            let egressRegion = null
+            if (redisEntry?.server) {
+                const nip = extractIpFromServer(redisEntry.server)
+                nodeRegion = nip ? await lookupIpv4Region(nip) : null
+            }
+            if (redisEntry?.proxyIp) egressRegion = await lookupIpv4Region(redisEntry.proxyIp)
+
+            let lastFetchEnriched = snap.last_fetch_log || null
+            if (lastFetchEnriched?.detail) {
+                const d = parseDetail(lastFetchEnriched.detail)
+                const pip = d.proxy_ip
+                lastFetchEnriched = {
+                    ...lastFetchEnriched,
+                    proxy_ip_region:
+                        pip && String(pip).match(/^(\d{1,3}\.){3}\d{1,3}$/) ? await lookupIpv4Region(pip) : null
+                }
+            }
+
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    proxy_enabled: snap.proxy_enabled,
+                    area: snap.area,
+                    area_ex: snap.area_ex,
+                    isp: snap.isp,
+                    distinct_extract: snap.distinct_extract,
+                    updated_at: snap.updated_at,
+                    extract_key_configured: snap.extract_key_configured,
+                    proxy_auth_configured: snap.proxy_auth_configured,
+                    current_proxy: redisEntry
+                        ? {
+                              server: redisEntry.server,
+                              deadline: redisEntry.deadline,
+                              proxy_ip: redisEntry.proxyIp,
+                              request_id: redisEntry.requestId,
+                              fetched_at: redisEntry.fetchedAt,
+                              stale: !valid,
+                              node_region: nodeRegion || '未知',
+                              proxy_ip_region: egressRegion || '未知'
+                          }
+                        : null,
+                    last_fetch_log: lastFetchEnriched
+                }
+            })
+        } catch (e) {
+            this.logger?.error?.(`AdminLepaoProxyStatus: ${e.stack || e}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '读取代理状态失败' })
+        }
+    }
+}
+
+module.exports.AdminLepaoProxyStatus = AdminLepaoProxyStatus

+ 5 - 0
config.json

@@ -73,6 +73,11 @@
         "return_url": "https://xxoo365.top/uniLogin/loginSuccess",
         "return_url": "https://xxoo365.top/uniLogin/loginSuccess",
         "uni_return_url": "https://m.xxoo365.top/#/pages/login/login"
         "uni_return_url": "https://m.xxoo365.top/#/pages/login/login"
     },
     },
+    "qgChannelProxy": {
+        "extractKey": "D506DCB9",
+        "authUser": "D506DCB9",
+        "authPassword": "B4554DA1F959"
+    },
     "server": "CTBU_CLUB 重庆1号服务器",
     "server": "CTBU_CLUB 重庆1号服务器",
     "onebotv11": {
     "onebotv11": {
         "enabled": true,
         "enabled": true,

+ 401 - 0
lib/Lepao/QgProxyManager.js

@@ -0,0 +1,401 @@
+const axios = require('axios')
+const path = require('path')
+const config = require('../../config.json')
+const db = require('../../plugin/DataBase/db')
+const Redis = require('../../plugin/DataBase/Redis')
+const Logger = require('../Logger')
+
+const QG_GET_URL = 'https://share.proxy.qg.net/get'
+const QG_RESOURCES_URL = 'https://share.proxy.qg.net/resources'
+const REDIS_CURRENT = 'lepao:qg:current'
+const REDIS_LOCK = 'lepao:qg:fetch_lock'
+/** 早于 deadline 提前刷新,尽量减少「边检边过期」(参考 [文档](https://www.qg.net/doc/1850.html)) */
+const DEADLINE_MARGIN_MS = 18_000
+const LOCK_TTL_SEC = 75
+const LOCK_WAIT_ROUNDS = 40
+const LOCK_WAIT_MS = 150
+
+const logger = new Logger(path.join(__dirname, '../logs/QgProxyManager.log'), 'INFO')
+
+function sleep(ms) {
+    return new Promise(r => setTimeout(r, ms))
+}
+
+function getQgConfig() {
+    const q = config.qgChannelProxy
+    if (!q || typeof q !== 'object') return {}
+    return {
+        extractKey: String(q.extractKey || '').trim(),
+        authUser: String(q.authUser || '').trim(),
+        authPassword: String(q.authPassword || '').trim()
+    }
+}
+
+function hasExtractCredentials() {
+    const { extractKey } = getQgConfig()
+    return extractKey.length > 0
+}
+
+function hasProxyAuth() {
+    const { authUser, authPassword } = getQgConfig()
+    return authUser.length > 0 && authPassword.length > 0
+}
+
+async function ensureSettingsRow() {
+    const now = Date.now()
+    await db.query(
+        `INSERT IGNORE INTO lepao_proxy_settings (id, proxy_enabled, area, area_ex, isp, distinct_extract, updated_at)
+         VALUES (1, 0, '', '', NULL, 1, ?)`,
+        [now]
+    )
+}
+
+async function loadSettings() {
+    await ensureSettingsRow()
+    const rows = await db.query(
+        `SELECT proxy_enabled, area, area_ex, isp, distinct_extract, updated_at FROM lepao_proxy_settings WHERE id = 1`
+    )
+    return rows?.[0] || null
+}
+
+function parseDeadlineMs(deadlineStr) {
+    if (!deadlineStr || typeof deadlineStr !== 'string') return null
+    const isoish = deadlineStr.trim().replace(' ', 'T')
+    const ms = Date.parse(isoish)
+    return Number.isFinite(ms) ? ms : null
+}
+
+function axiosProxyOptsFromServer(server, useAuth) {
+    if (!server || typeof server !== 'string') return { proxy: false }
+    const parts = server.trim().split(':')
+    const host = parts[0]
+    const portNum = Number(parts[1])
+    if (!host || !Number.isFinite(portNum)) return { proxy: false }
+    const opt = {
+        proxy: {
+            protocol: 'http',
+            host,
+            port: portNum
+        }
+    }
+    if (useAuth) {
+        const { authUser, authPassword } = getQgConfig()
+        opt.proxy.auth = { username: authUser, password: authPassword }
+    }
+    return opt
+}
+
+async function recordLog({ event, server = null, deadline = null, detail = null }) {
+    try {
+        const detailStr =
+            typeof detail === 'string' ? detail.slice(0, 8000) : JSON.stringify(detail || {}).slice(0, 8000)
+        await db.query(
+            `INSERT INTO lepao_proxy_log (created_at, event, server, deadline, detail) VALUES (?, ?, ?, ?, ?)`,
+            [Date.now(), event, server, deadline, detailStr]
+        )
+    } catch (e) {
+        logger.error(`lepao_proxy_log 写入失败: ${e.stack || e}`)
+    }
+}
+
+async function getCachedParsed() {
+    const raw = await Redis.get(REDIS_CURRENT)
+    if (!raw) return null
+    try {
+        return JSON.parse(raw)
+    } catch {
+        return null
+    }
+}
+
+function cacheStillValid(parsed) {
+    if (!parsed || !parsed.server) return false
+    const ms = parsed.deadlineMs || parseDeadlineMs(parsed.deadline)
+    if (!ms) return false
+    return Date.now() + DEADLINE_MARGIN_MS < ms
+}
+
+async function acquireFetchLock() {
+    for (let i = 0; i < LOCK_WAIT_ROUNDS; i++) {
+        const ok = await Redis.set(REDIS_LOCK, '1', { NX: true, EX: LOCK_TTL_SEC })
+        if (ok) return true
+        await sleep(LOCK_WAIT_MS)
+    }
+    return false
+}
+
+async function releaseFetchLock() {
+    try {
+        await Redis.del(REDIS_LOCK)
+    } catch (e) {
+        logger.warn(`释放青果 fetch 锁失败: ${e.message || e}`)
+    }
+}
+
+/** 瞬时故障 / 通道释放延迟等可 backoff 再试(见青果通道提取说明) */
+const RETRYABLE_EXTRACT_CODES = new Set([
+    'NO_AVAILABLE_CHANNEL',
+    'REQUEST_LIMIT_EXCEEDED',
+    'INTERNAL_ERROR',
+    'FAILED_OPERATION',
+    'NO_RESOURCE_FOUND'
+])
+
+function buildGetParams(settings) {
+    const { extractKey } = getQgConfig()
+    const params = {
+        key: extractKey,
+        num: 1
+    }
+    const area = String(settings.area || '').trim()
+    const areaEx = String(settings.area_ex || '').trim()
+    if (area) params.area = area
+    if (areaEx) params.area_ex = areaEx
+    const isp = settings.isp
+    if (isp === 1 || isp === 2 || isp === 3) params.isp = isp
+    params.distinct = Number(settings.distinct_extract) === 1
+    return params
+}
+
+async function extractOnce(settings) {
+    const params = buildGetParams(settings)
+    const res = await axios.get(QG_GET_URL, {
+        params,
+        timeout: 20000,
+        proxy: false,
+        validateStatus: () => true
+    })
+
+    const body = res.data
+    const code = body?.code
+    if (code === 'SUCCESS' && Array.isArray(body?.data) && body.data.length >= 1) {
+        return { body, item: body.data[0], code }
+    }
+
+    const err = new Error(`青果提取失败: ${code || JSON.stringify(body || {}).slice(0, 200)}`)
+    err.qgCode = code
+    err.retryable = RETRYABLE_EXTRACT_CODES.has(code)
+    throw err
+}
+
+async function fetchFromQgUnderLock(settings) {
+    let lastErr
+    const maxAttempts = 8
+
+    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+        try {
+            const { body, item, code } = await extractOnce(settings)
+            const server = item.server
+            const deadline = item.deadline
+            const proxyIp = item.proxy_ip
+            const requestId = body.request_id
+            if (!server) throw new Error('青果返回无 server 字段')
+
+            const deadlineMs = parseDeadlineMs(deadline)
+            const ttlSec = deadlineMs
+                ? Math.max(15, Math.floor((deadlineMs - Date.now()) / 1000) - 2)
+                : 55
+
+            const payload = {
+                server,
+                deadline: deadline || '',
+                deadlineMs: deadlineMs || null,
+                proxyIp: proxyIp || null,
+                requestId: requestId || null,
+                fetchedAt: Date.now()
+            }
+            await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.min(120, Math.max(15, ttlSec)) })
+
+            if (attempt > 1) {
+                logger.info(`[QgProxy] 第 ${attempt} 次 /get 成功`)
+            }
+
+            logger.info(
+                `[QgProxy] 已获取代理节点 server=${server} proxy_ip=${proxyIp ?? '—'} deadline=${deadline || '—'} request_id=${requestId ?? '—'}`
+            )
+
+            await recordLog({
+                event: 'fetch',
+                server,
+                deadline: deadline || null,
+                detail: { request_id: requestId, proxy_ip: proxyIp, code }
+            })
+
+            return payload
+        } catch (e) {
+            lastErr = e
+            const retryable = e.retryable === true
+            if (!retryable || attempt >= maxAttempts) {
+                throw e
+            }
+            const backoff = Math.min(5200, 380 * attempt * attempt)
+            logger.warn(`[QgProxy] /get 将重试 (${attempt}/${maxAttempts}) ${e.message},等待 ${backoff}ms`)
+            await sleep(backoff)
+        }
+    }
+
+    throw lastErr || new Error('青果提取失败')
+}
+
+/**
+ * 通道提取:[查询资源地区](https://www.qg.net/doc/1850.html) GET /resources
+ */
+async function fetchResourceAreas() {
+    const { extractKey } = getQgConfig()
+    if (!extractKey) {
+        throw new Error('config 未配置 qgChannelProxy.extractKey')
+    }
+    const res = await axios.get(QG_RESOURCES_URL, {
+        params: { key: extractKey },
+        timeout: 20000,
+        proxy: false,
+        validateStatus: () => true
+    })
+    const body = res.data
+    if (body?.code !== 'SUCCESS' || !Array.isArray(body?.data)) {
+        throw new Error(body?.code || '青果 resources 查询失败')
+    }
+    return body.data
+}
+
+/**
+ * 是否应在业务层尝试青果(DB 开关 + config 里存在 extractKey)
+ */
+async function isOutboundProxyEnabled() {
+    if (!hasExtractCredentials()) return false
+    const row = await loadSettings()
+    return row && Number(row.proxy_enabled) === 1
+}
+
+/**
+ * 对外:得到可合并进 axios 的代理段;未启用则 { proxy: false }
+ * @param {{ forceRefresh?: boolean }} opt
+ */
+async function getOutboundAxiosFragment(opt = {}) {
+    const forceRefresh = opt.forceRefresh === true
+    if (!hasExtractCredentials()) return { proxy: false }
+
+    const settings = await loadSettings()
+    if (!settings || Number(settings.proxy_enabled) !== 1) return { proxy: false }
+
+    if (!forceRefresh) {
+        const cached = await getCachedParsed()
+        if (cacheStillValid(cached)) {
+            return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
+        }
+        if (cached?.server) {
+            const ms = cached.deadlineMs || parseDeadlineMs(cached.deadline)
+            logger.info(
+                `[QgProxy] 缓存代理已失效或临近截止,将重新提取。原节点 server=${cached.server} proxy_ip=${cached.proxyIp ?? '—'} deadline_ms=${ms ?? '—'}`
+            )
+        }
+    }
+
+    const locked = await acquireFetchLock()
+    if (!locked) {
+        logger.warn('青果提取锁等待超时,本次尝试使用缓存或直连由调用方处理')
+        const cached = await getCachedParsed()
+        if (cached?.server) {
+            logger.warn(
+                `[QgProxy] 锁超时降级使用仍为缓存记录的节点 server=${cached.server} proxy_ip=${cached.proxyIp ?? '—'}(可能已过期)`
+            )
+            return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
+        }
+        return { proxy: false }
+    }
+
+    try {
+        if (!forceRefresh) {
+            const cached = await getCachedParsed()
+            if (cacheStillValid(cached)) {
+                return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
+            }
+        }
+
+        logger.info(`[QgProxy] 调用青果 /get 拉取新代理... forceRefresh=${forceRefresh}`)
+        const fresh = await fetchFromQgUnderLock(settings)
+        return axiosProxyOptsFromServer(fresh.server, hasProxyAuth())
+    } catch (e) {
+        logger.error(`青果拉取异常: ${e.stack || e}`)
+        throw e
+    } finally {
+        await releaseFetchLock()
+    }
+}
+
+async function invalidateCurrent(reason, detail) {
+    let prev = null
+    try {
+        prev = await getCachedParsed()
+        await Redis.del(REDIS_CURRENT)
+    } catch (e) {
+        logger.warn(`清空青果缓存失败: ${e.message || e}`)
+    }
+    const detailObj =
+        typeof detail === 'object' && detail !== null ? detail : { message: String(detail || '') }
+    logger.info(
+        `[QgProxy] 已作废当前代理缓存 reason=${reason || 'unknown'} 原server=${prev?.server ?? '(无)'} 原proxy_ip=${prev?.proxyIp ?? '—'} 原deadline=${prev?.deadline ?? '—'} detail=${JSON.stringify(detailObj)}`
+    )
+    await recordLog({
+        event: 'invalidate',
+        server: prev?.server ?? null,
+        deadline: prev?.deadline ?? null,
+        detail: { reason: reason || 'unknown', ...detailObj, proxy_ip: prev?.proxyIp ?? detailObj?.proxy_ip ?? null }
+    })
+}
+
+async function recordFallbackDirect(detail) {
+    const d = typeof detail === 'object' && detail !== null ? detail : { message: String(detail || '') }
+    logger.warn(`[QgProxy] 乐跑出站回退直连 reason=${JSON.stringify(d)}`)
+    await recordLog({
+        event: 'fallback_direct',
+        detail: d
+    })
+}
+
+async function getStatusSnapshot() {
+    await ensureSettingsRow()
+    const row = await loadSettings()
+    const cached = await getCachedParsed()
+    let lastFetch = null
+    try {
+        const lr = await db.query(
+            `SELECT server, deadline, created_at, detail FROM lepao_proxy_log WHERE event = 'fetch' ORDER BY id DESC LIMIT 1`
+        )
+        lastFetch = lr?.[0] || null
+    } catch {
+        lastFetch = null
+    }
+
+    return {
+        proxy_enabled: row ? Number(row.proxy_enabled) === 1 : false,
+        area: row?.area ?? '',
+        area_ex: row?.area_ex ?? '',
+        isp: row?.isp == null ? null : Number(row.isp),
+        distinct_extract: row ? Number(row.distinct_extract) === 1 : true,
+        updated_at: row?.updated_at ?? 0,
+        extract_key_configured: hasExtractCredentials(),
+        proxy_auth_configured: hasProxyAuth(),
+        redis_current: cached && cacheStillValid(cached) ? cached : cached,
+        last_fetch_log: lastFetch
+    }
+}
+
+module.exports = {
+    getQgConfig,
+    hasExtractCredentials,
+    hasProxyAuth,
+    ensureSettingsRow,
+    loadSettings,
+    isOutboundProxyEnabled,
+    getOutboundAxiosFragment,
+    invalidateCurrent,
+    recordFallbackDirect,
+    recordLog,
+    getStatusSnapshot,
+    parseDeadlineMs,
+    cacheStillValid,
+    getCachedParsed,
+    fetchResourceAreas,
+    REDIS_CURRENT
+}

+ 7 - 28
lib/Lepao/Worker.js

@@ -1,5 +1,4 @@
 const path = require('path')
 const path = require('path')
-const axios = require('axios')
 const OSS = require('ali-oss')
 const OSS = require('ali-oss')
 const mq = require('../../plugin/mq')
 const mq = require('../../plugin/mq')
 const { mq: mqName } = require('../../plugin/mq/mqPrefix')
 const { mq: mqName } = require('../../plugin/mq/mqPrefix')
@@ -21,6 +20,7 @@ const {
 } = require('../../plugin/Lepao/Crypto')
 } = require('../../plugin/Lepao/Crypto')
 const generateGyrFromPath = require('../../plugin/Lepao/generateGyrFromPath')
 const generateGyrFromPath = require('../../plugin/Lepao/generateGyrFromPath')
 const { syncAccountInfo } = require('./syncAccountInfo')
 const { syncAccountInfo } = require('./syncAccountInfo')
+const { postLepaoSchool } = require('./lepaoSchoolHttp')
 const { insertLedgerRecord } = require('./CountLedger')
 const { insertLedgerRecord } = require('./CountLedger')
 
 
 const Logger = require('../Logger')
 const Logger = require('../Logger')
@@ -50,11 +50,7 @@ class Worker {
 
 
         this.defaultUserAgent = '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 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'
         this.defaultUserAgent = '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 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'
 
 
-        // 调试模式:将 axios 请求走本地代理(例如 charles/fiddler)
-        // 开启方式:设置环境变量 LEPAO_DEBUG_PROXY=1
-        this.debugProxyEnabled = String(process.env.LEPAO_DEBUG_PROXY || '').trim() === '1'
-        this.debugProxyHost = process.env.LEPAO_DEBUG_PROXY_HOST || '127.0.0.1'
-        this.debugProxyPort = Number(process.env.LEPAO_DEBUG_PROXY_PORT || 9000)
+        // HTTP 出站(charles/fiddler / 青果)由 lepaoSchoolHttp 统一管理:LEPAO_DEBUG_PROXY=1 时强制走本地调试代理。
     }
     }
 
 
     /* ================= 工具 ================= */
     /* ================= 工具 ================= */
@@ -196,20 +192,6 @@ class Worker {
         return Number((Date.now() / 1000).toFixed(3))
         return Number((Date.now() / 1000).toFixed(3))
     }
     }
 
 
-    axiosProxyConfig() {
-        if (!this.debugProxyEnabled) {
-            return { proxy: false }
-        }
-        this.logger.info(`使用本地代理: ${this.debugProxyHost}:${this.debugProxyPort}`)
-        return {
-            proxy: {
-                host: this.debugProxyHost,
-                port: this.debugProxyPort,
-                protocol: 'http'
-            }
-        }
-    }
-
     async enqueueTask(channel, type, data, options = {}) {
     async enqueueTask(channel, type, data, options = {}) {
         const payload = {
         const payload = {
             id: options.id || this.traceId(),
             id: options.id || this.traceId(),
@@ -313,14 +295,11 @@ class Worker {
             form.append('data', dataEncrypt(JSON.stringify(raw)))
             form.append('data', dataEncrypt(JSON.stringify(raw)))
 
 
             const res = await this.withTimeout(
             const res = await this.withTimeout(
-                axios.post(
-                    url,
-                    form,
-                    {
-                        headers: mergedHeaders,
-                        ...this.axiosProxyConfig()
-                    }
-                ),
+                postLepaoSchool(url, form, {
+                    headers: mergedHeaders,
+                    timeout: this.timeout,
+                    logger: this.logger
+                }),
                 name
                 name
             )
             )
 
 

+ 45 - 0
lib/Lepao/ipRegionLookup.js

@@ -0,0 +1,45 @@
+/**
+ * 与 requestLog 相同:本地 ip2region.xdb,仅支持 IPv4。
+ */
+const path = require('path')
+const ipSearcher = require('../../plugin/ip2region')
+
+let _searcher = null
+
+function getSearcher() {
+    if (!_searcher) {
+        _searcher = ipSearcher.newWithFileOnly(path.join(__dirname, '../../plugin/ip2region/ip2region.xdb'))
+    }
+    return _searcher
+}
+
+/**
+ * @param {string|null|undefined} ip
+ * @returns {Promise<string>} 可读属地,失败或非法为「未知」
+ */
+async function lookupIpv4Region(ip) {
+    const s = String(ip || '').trim()
+    if (!s || !ipSearcher.isValidIp(s)) return '未知'
+    try {
+        const r = await getSearcher().search(s)
+        const raw = r?.region
+        if (!raw || typeof raw !== 'string') return '未知'
+        return raw.split('|').filter(Boolean).join(' · ')
+    } catch {
+        return '未知'
+    }
+}
+
+/**
+ * 从 server 字段形如 host:port 取 IP
+ */
+function extractIpFromServer(server) {
+    if (!server || typeof server !== 'string') return null
+    const host = server.split(':')[0].trim()
+    return ipSearcher.isValidIp(host) ? host : null
+}
+
+module.exports = {
+    lookupIpv4Region,
+    extractIpFromServer
+}

+ 92 - 0
lib/Lepao/lepaoProxyLogDisplay.js

@@ -0,0 +1,92 @@
+/**
+ * 管理员列表:可读摘要 + Arco Tag 色号(与设计约定一致)。
+ */
+function parseDetail(raw) {
+    if (raw == null || raw === '') return {}
+    if (typeof raw === 'object') return raw
+    try {
+        return JSON.parse(raw)
+    } catch {
+        return { _text: String(raw) }
+    }
+}
+
+const EVENT_META = {
+    fetch: { label: '提取 IP', color: 'green' },
+    invalidate: { label: '作废缓存', color: 'orangered' },
+    fallback_direct: { label: '回退直连', color: 'red' },
+    config_change: { label: '配置变更', color: 'arcoblue' }
+}
+
+function summarizeLogRow(record) {
+    const event = record.event
+    const d = parseDetail(record.detail)
+    const lines = []
+
+    if (event === 'fetch') {
+        if (d.request_id) lines.push(`请求 ID:${d.request_id}`)
+        if (d.code) lines.push(`接口状态:${d.code}`)
+    } else if (event === 'invalidate') {
+        if (d.reason) lines.push(`原因:${d.reason}`)
+        if (d.message) lines.push(`说明:${d.message}`)
+        if (d.code) lines.push(`错误码:${d.code}`)
+        if (d.status) lines.push(`HTTP:${d.status}`)
+    } else if (event === 'fallback_direct') {
+        if (d.reason) lines.push(`触发原因:${d.reason}`)
+        if (d.reason === 'exhausted_proxy_then_direct') {
+            lines.push('多轮提取与经代理 POST 均未成功,已改直连对学校接口')
+        } else if (d.message) {
+            lines.push(`说明:${d.message}`)
+        }
+        if (d.after) lines.push(`阶段:${d.after}`)
+        if (d.code) lines.push(`错误码:${d.code}`)
+    } else if (event === 'config_change') {
+        lines.push(`代理开关:${d.proxy_enabled === 1 ? '开' : '关'}`)
+        if (d.area !== undefined) lines.push(`地区 area:「${d.area || '(空)'}」`)
+        if (d.area_ex !== undefined) lines.push(`排除 area_ex:「${d.area_ex || '(空)'}」`)
+        if (d.isp !== undefined) lines.push(`运营商 isp:${d.isp ?? '不限'}`)
+        if (d.distinct_extract !== undefined) lines.push(`去重提取:${d.distinct_extract ? '是' : '否'}`)
+        if (d.invalidate_cache) lines.push('已勾选清空服务端 IP 缓存')
+        if (d.operator) lines.push(`操作者 UUID:${d.operator}`)
+    } else if (Object.keys(d).length) {
+        if (d._text) lines.push(String(d._text))
+        else {
+            Object.keys(d).slice(0, 6).forEach(k => {
+                lines.push(`${k}:${typeof d[k] === 'object' ? JSON.stringify(d[k]) : d[k]}`)
+            })
+        }
+    }
+
+    let summary = lines.length ? lines.join(';') : '—'
+
+    const meta = EVENT_META[event] || { label: event || '未知', color: 'gray' }
+    const serverShown = record.server ? `节点 ${record.server}` : ''
+
+    return {
+        event_label: meta.label,
+        event_color: meta.color,
+        summary,
+        detail_lines: lines,
+        server_tip: serverShown || null,
+        ...(record.server && record.deadline ? { deadline_tip: `${record.server} · ${record.deadline}` } : {})
+    }
+}
+
+/**
+ * 青果语义下的出口 IP(proxy_ip),非代理节点 host。
+ */
+function extractEgressIp(record) {
+    const d = parseDetail(record.detail)
+    const p = d.proxy_ip
+    if (p === null || p === undefined || p === '') return null
+    const s = String(p).trim()
+    return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) ? s : null
+}
+
+module.exports = {
+    summarizeLogRow,
+    parseDetail,
+    EVENT_META,
+    extractEgressIp
+}
+

+ 260 - 0
lib/Lepao/lepaoSchoolHttp.js

@@ -0,0 +1,260 @@
+const axios = require('axios')
+const HttpsProxyAgent = require('https-proxy-agent')
+const QgProxyManager = require('./QgProxyManager')
+
+function sleep(ms) {
+    return new Promise(r => setTimeout(r, ms))
+}
+
+/** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动 */
+async function getOutboundWithBackoff(qgOpts, rounds = 5) {
+    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
+}
+
+/**
+ * Axios 对「HTTPS 目标 + 内置 proxy」在部分环境下会引发 ERR_FR_TOO_MANY_REDIRECTS,
+ * 改用 HttpsProxyAgent 走 CONNECT 隧道。
+ */
+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}`
+    return {
+        proxy: false,
+        httpsAgent: new HttpsProxyAgent(proxyUrl)
+    }
+}
+
+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) 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
+    }
+}
+
+/**
+ * @param {*} logger Worker logger 或 null
+ * @param {{ skipQgSnapshot?: boolean }} opts 为 true 时仅用 axios 配置的 host:port(如 Charles)
+ */
+async function logSchoolOutbound(logger, phase, url, axiosMerge, opts = {}) {
+    if (!logger?.info) return
+    const path = briefUrlPath(url)
+    if (!axiosMerge || axiosMerge.proxy === false || !axiosMerge.proxy) {
+        logger.info(`[lepaoSchoolHttp] ${phase} 对学校 POST 出站=直连 path=${path}`)
+        return
+    }
+    const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}`
+    if (opts.skipQgSnapshot) {
+        logger.info(
+            `[lepaoSchoolHttp] ${phase} 对学校 POST 出站=调试HTTP代理(非青果) 连接=${conn} path=${path}`
+        )
+        return
+    }
+    const snap = await QgProxyManager.getCachedParsed()
+    const serverRecord = snap?.server ?? conn
+    const egress = snap?.proxyIp ?? '(暂无 proxy_ip)'
+    const dl = snap?.deadline ?? '—'
+    logger.info(
+        `[lepaoSchoolHttp] ${phase} 对学校 POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
+    )
+}
+
+/**
+ * 对学校 lepao.ctbu.edu.cn 的 POST:优先青果代理;失败轮换一次;再失败直连并记日志。
+ */
+async function postLepaoSchool(url, data, options = {}) {
+    const { headers = {}, timeout = 15000, logger = null } = options
+
+    const doPost = async (qgProxyFragment) => {
+        const outbound = buildAxiosOutboundConfig(qgProxyFragment)
+        return axios.post(url, data, {
+            headers,
+            timeout,
+            ...outbound
+        })
+    }
+
+    if (debugProxyEnabled()) {
+        const dbg = debugProxyAxiosFragment()
+        await logSchoolOutbound(logger, 'Charles调试代理', url, dbg, { skipQgSnapshot: true })
+        logger?.info?.('[lepaoSchoolHttp] 使用本地调试代理 LEPAO_DEBUG_PROXY')
+        return doPost(dbg)
+    }
+
+    const qgOn = await QgProxyManager.isOutboundProxyEnabled()
+    if (!qgOn) {
+        await logSchoolOutbound(logger, '(青果出站未启用)', url, { proxy: false })
+        return doPost({ proxy: false })
+    }
+
+    let frag
+    try {
+        frag = await getOutboundWithBackoff({ forceRefresh: false }, 5)
+    } catch (e0) {
+        logger?.error?.(`[lepaoSchoolHttp] 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
+        await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false })
+        await QgProxyManager.recordFallbackDirect({
+            reason: 'qg_extract_error',
+            ...summarizeAxiosError(e0)
+        })
+        return doPost({ proxy: false })
+    }
+
+    if (frag.proxy === false) {
+        try {
+            await sleep(600)
+            frag = await getOutboundWithBackoff({ forceRefresh: true }, 4)
+        } catch {
+            /* 保持 frag 原状 */
+        }
+    }
+
+    if (frag.proxy === false) {
+        logger?.warn?.('[lepaoSchoolHttp] 无可用青果节点,对学校 POST 将直连')
+        await logSchoolOutbound(logger, '(无缓存节点→直连)', url, { proxy: false })
+        await QgProxyManager.recordFallbackDirect({ reason: 'no_proxy_available' })
+        return doPost({ proxy: false })
+    }
+
+    await logSchoolOutbound(logger, '首次请求', url, frag)
+    try {
+        return await doPost(frag)
+    } catch (e1) {
+        if (!isQgProxyEligibleFailure(e1)) throw e1
+        logger?.warn?.(
+            `[lepaoSchoolHttp] 经代理首次请求失败,将作废IP并换新。err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
+        )
+        await QgProxyManager.invalidateCurrent('request_fail', summarizeAxiosError(e1))
+
+        // 通道数为 1 时,服务端释放上一 IP 需要时间;与 QgProxyManager 内 /get 多轮重试配合
+        await sleep(1400)
+
+        let frag2
+        try {
+            frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 5)
+        } catch (eFetch) {
+            logger?.warn?.(`[lepaoSchoolHttp] 换新 IP 提取失败:${eFetch.message || eFetch}`)
+            await logSchoolOutbound(logger, '(换新提取失败→将加试直连前再试)', url, { proxy: false })
+            frag2 = { proxy: false }
+        }
+
+        if (frag2.proxy === false) {
+            frag2 = null
+        }
+
+        if (frag2) {
+            await logSchoolOutbound(logger, '换新IP重试', url, frag2)
+            try {
+                return await doPost(frag2)
+            } catch (e2) {
+                if (!isQgProxyEligibleFailure(e2)) throw e2
+                logger?.warn?.(
+                    `[lepaoSchoolHttp] 换新IP后仍失败,将进入加试阶段。err=${e2.message || e2} ${JSON.stringify(
+                        summarizeAxiosError(e2)
+                    )}`
+                )
+                await QgProxyManager.invalidateCurrent('retry_round_post_fail', summarizeAxiosError(e2))
+            }
+        }
+
+        for (let extra = 0; extra < 5; extra++) {
+            await sleep(750 + 400 * extra * extra)
+            try {
+                const fragX = await getOutboundWithBackoff({ forceRefresh: true }, 5)
+                if (fragX.proxy === false) continue
+                await logSchoolOutbound(logger, `补充轮次-${extra + 1}`, url, fragX)
+                try {
+                    return await doPost(fragX)
+                } catch (e4) {
+                    if (!isQgProxyEligibleFailure(e4)) throw e4
+                    await QgProxyManager.invalidateCurrent('extra_round_fail', summarizeAxiosError(e4))
+                }
+            } catch (eGrab) {
+                logger?.warn?.(`[lepaoSchoolHttp] 补充轮提取失败 (${extra + 1}): ${eGrab.message || eGrab}`)
+            }
+        }
+
+        logger?.warn?.(`[lepaoSchoolHttp] 多轮代理均不可用,回退直连 path=${briefUrlPath(url)}`)
+        await logSchoolOutbound(logger, '(多轮失败后→直连)', url, { proxy: false })
+        await QgProxyManager.recordFallbackDirect({
+            reason: 'exhausted_proxy_then_direct',
+            message: lastNote()
+        })
+        return doPost({ proxy: false })
+    }
+}
+
+function lastNote() {
+    return '已用尽提取与 POST 加试仍失败或未取到可用节点'
+}
+
+module.exports = {
+    postLepaoSchool,
+    isQgProxyEligibleFailure,
+    debugProxyEnabled,
+    debugProxyAxiosFragment
+}

+ 3 - 3
lib/Lepao/syncAccountInfo.js

@@ -1,5 +1,5 @@
-const axios = require('axios')
 const db = require('../../plugin/DataBase/db')
 const db = require('../../plugin/DataBase/db')
+const { postLepaoSchool } = require('./lepaoSchoolHttp')
 const { URLSearchParams } = require('url')
 const { URLSearchParams } = require('url')
 const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
 const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
 
 
@@ -54,10 +54,10 @@ async function syncAccountInfo({ studentNum, createUser, logger }) {
 
 
     let result
     let result
     try {
     try {
-        const apiRes = await axios.post(
+        const apiRes = await postLepaoSchool(
             'https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260',
             'https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260',
             form,
             form,
-            { headers, proxy: false }
+            { headers, timeout: 20000, logger }
         )
         )
 
 
         result = apiRes.data
         result = apiRes.data

+ 26 - 0
package-lock.json

@@ -17,6 +17,7 @@
         "chalk": "^4.1.2",
         "chalk": "^4.1.2",
         "cors": "^2.8.5",
         "cors": "^2.8.5",
         "express": "^4.21.1",
         "express": "^4.21.1",
+        "https-proxy-agent": "^5.0.1",
         "multer": "^1.4.5-lts.1",
         "multer": "^1.4.5-lts.1",
         "mysql2": "^3.11.0",
         "mysql2": "^3.11.0",
         "node-forge": "^1.3.1",
         "node-forge": "^1.3.1",
@@ -306,6 +307,18 @@
         "node": ">= 10.0.0"
         "node": ">= 10.0.0"
       }
       }
     },
     },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
     "node_modules/agentkeepalive": {
     "node_modules/agentkeepalive": {
       "version": "3.5.3",
       "version": "3.5.3",
       "license": "MIT",
       "license": "MIT",
@@ -1403,6 +1416,19 @@
         "url": "https://opencollective.com/express"
         "url": "https://opencollective.com/express"
       }
       }
     },
     },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/humanize-ms": {
     "node_modules/humanize-ms": {
       "version": "1.2.1",
       "version": "1.2.1",
       "license": "MIT",
       "license": "MIT",

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "chalk": "^4.1.2",
     "chalk": "^4.1.2",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
     "express": "^4.21.1",
     "express": "^4.21.1",
+    "https-proxy-agent": "^5.0.1",
     "multer": "^1.4.5-lts.1",
     "multer": "^1.4.5-lts.1",
     "mysql2": "^3.11.0",
     "mysql2": "^3.11.0",
     "node-forge": "^1.3.1",
     "node-forge": "^1.3.1",

+ 3 - 4
plugin/Lepao/runforgeSetZoneProbe.js

@@ -1,8 +1,8 @@
 /**
 /**
  * 直连 RunForge 切换跑区(与 Worker lepao.setZone 一致),用于 token 探活,不经过 runpy。
  * 直连 RunForge 切换跑区(与 Worker lepao.setZone 一致),用于 token 探活,不经过 runpy。
  */
  */
-const axios = require('axios')
 const { URLSearchParams } = require('url')
 const { URLSearchParams } = require('url')
+const { postLepaoSchool } = require('../../lib/Lepao/lepaoSchoolHttp')
 const db = require('../DataBase/db')
 const db = require('../DataBase/db')
 const { dataEncrypt, dataDecrypt, dataSign } = require('./Crypto')
 const { dataEncrypt, dataDecrypt, dataSign } = require('./Crypto')
 
 
@@ -64,7 +64,7 @@ async function probeSetZone(p) {
     form.append('ostype', '5')
     form.append('ostype', '5')
     form.append('data', dataEncrypt(JSON.stringify(raw)))
     form.append('data', dataEncrypt(JSON.stringify(raw)))
 
 
-    const res = await axios.post(`${BASE_URL}/Run/setRunZone`, form, {
+    const res = await postLepaoSchool(`${BASE_URL}/Run/setRunZone`, form, {
         headers: {
         headers: {
             'Content-Type': 'application/x-www-form-urlencoded',
             'Content-Type': 'application/x-www-form-urlencoded',
             Accept: '*/*',
             Accept: '*/*',
@@ -73,8 +73,7 @@ async function probeSetZone(p) {
             Referer: 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
             Referer: 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
             charset: 'utf-8'
             charset: 'utf-8'
         },
         },
-        timeout: 20000,
-        proxy: false
+        timeout: 20000
     })
     })
 
 
     let result = res.data
     let result = res.data