/** * 乐跑账号出站代理:全局开关、粘性分配、网络失败轮换、全失败直连。 * 本地调试 LEPAO_DEBUG_PROXY=1 时由调用方 axios 配置优先,不走账号代理池。 */ const db = require('../../plugin/DataBase/db') const GLOBAL_CACHE_MS = 5000 let globalCache = { at: 0, row: null } function invalidateGlobalCache() { globalCache = { at: 0, row: null } } function nowMs() { return Date.now() } async function getGlobalRow() { const t = nowMs() if (globalCache.row && t - globalCache.at < GLOBAL_CACHE_MS) { return globalCache.row } const rows = await db.query('SELECT * FROM lepao_proxy_global WHERE id = 1 LIMIT 1') const row = rows?.[0] || null globalCache = { at: t, row } return row } function isNetworkError(err) { if (!err) return false if (err.code && ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(err.code)) { return true } if (err.isAxiosError && !err.response) return true const msg = String(err.message || '').toLowerCase() return msg.includes('timeout') || msg.includes('network') } /** * 仅在“当前出口是代理”时,把这类 HTTP 状态码视为代理被封/限流/网关异常,允许切换出口。 * 直连请求不使用该规则,避免把真实业务 4xx/5xx 当成代理问题。 */ function isProxyHttpBlockError(err) { if (!err || !err.response) return false const status = Number(err.response.status) if (!Number.isFinite(status)) return false // 404 在代理场景下常见于代理网关/中间层返回(并非目标业务接口真实 404),允许轮换 if (status === 403 || status === 404 || status === 407 || status === 409 || status === 429) return true if (status >= 500 && status <= 599) return true return false } function isDebugAxiosProxy(debugAxiosOpts) { return Boolean(debugAxiosOpts && debugAxiosOpts.proxy && typeof debugAxiosOpts.proxy === 'object') } function axiosExtrasFromPoolRow(row) { if (!row) return { proxy: false } const protocol = String(row.scheme || 'http').toLowerCase() return { proxy: { protocol, host: row.host, port: Number(row.port) } } } function proxyUrlFromPoolRow(row) { if (!row) return null const protocol = String(row.scheme || 'http').toLowerCase() return `${protocol}://${row.host}:${Number(row.port)}` } function stepLabel(step) { if (!step || step.direct) return 'DIRECT' const protocol = String(step.row?.scheme || 'http').toLowerCase() return `${protocol}://${step.row?.host}:${Number(step.row?.port)}#${step.proxyId}` } /** * Python webvpn 返回是否应按「换代理/直连」重试 * @param {number|undefined} pyCode * @param {string|undefined} pyMsg */ function isPyOutboundRetryable(pyCode, pyMsg) { if (pyCode === 502) return true const m = String(pyMsg || '') if (pyCode === 500 && (m.includes('网络错误') || m.includes('网络连接') || m.includes('代理'))) return true return false } async function persistAssignedProxy(createUserUuid, studentNum, proxyId) { if (!createUserUuid || !studentNum) return const pid = proxyId == null ? null : Number(proxyId) await db.query( 'UPDATE lepao_account SET assigned_proxy_id = ? WHERE student_num = ? AND create_user = ?', [pid, studentNum, createUserUuid] ) } /** * 返回活跃代理及绑定账号数,用于排序 */ async function loadActivePoolWithBindCount() { const rows = await db.query(` SELECT p.*, (SELECT COUNT(*) FROM lepao_account a WHERE a.assigned_proxy_id = p.id) AS bind_cnt FROM lepao_proxy_pool p WHERE p.is_active = 1 `) return Array.isArray(rows) ? rows : [] } /** * @returns {Promise>} */ async function buildAttemptSequence(createUserUuid, studentNum) { const directOnly = [{ direct: true, proxyId: null, row: null }] if (!createUserUuid || !studentNum) { return directOnly } const g = await getGlobalRow() if (!g || Number(g.random_proxy_enabled) !== 1) { return directOnly } const pool = await loadActivePoolWithBindCount() if (!pool.length) { return directOnly } const accRows = await db.query( 'SELECT assigned_proxy_id FROM lepao_account WHERE student_num = ? AND create_user = ? LIMIT 1', [studentNum, createUserUuid] ) const assignedId = accRows?.[0]?.assigned_proxy_id != null ? Number(accRows[0].assigned_proxy_id) : null const tries = [] const seen = new Set() if (assignedId) { const sticky = pool.find((r) => Number(r.id) === assignedId) if (sticky) { tries.push({ direct: false, proxyId: sticky.id, row: sticky }) seen.add(Number(sticky.id)) } } const rest = pool.filter((r) => !seen.has(Number(r.id))) rest.sort((a, b) => { const d = Number(a.bind_cnt) - Number(b.bind_cnt) if (d !== 0) return d return Math.random() - 0.5 }) for (const r of rest) { tries.push({ direct: false, proxyId: r.id, row: r }) } tries.push({ direct: true, proxyId: null, row: null }) return tries } /** * @template T * @param {(axiosExtra: object) => Promise} postFn * @param {{ createUserUuid?: string|null, studentNum?: string|null, debugAxiosOpts: object, logger?: object }} ctx * @returns {Promise} */ async function withLepaoAccountProxy(postFn, ctx) { const { createUserUuid, studentNum, debugAxiosOpts, logger } = ctx if (isDebugAxiosProxy(debugAxiosOpts)) { return postFn(debugAxiosOpts) } const seq = await buildAttemptSequence(createUserUuid || null, studentNum || null) let lastErr for (const step of seq) { const ax = step.direct ? { proxy: false } : axiosExtrasFromPoolRow(step.row) logger?.info?.( `[lepaoOutboundProxy] 出站请求 student=${studentNum || 'unknown'} via=${stepLabel(step)}` ) try { const res = await postFn(ax) if (createUserUuid && studentNum) { if (step.direct) { await persistAssignedProxy(createUserUuid, studentNum, null) } else { await persistAssignedProxy(createUserUuid, studentNum, step.proxyId) } } logger?.info?.( `[lepaoOutboundProxy] 出站成功 student=${studentNum || 'unknown'} via=${stepLabel(step)}` ) return res } catch (e) { const canRotateByProxyHttp = !step.direct && isProxyHttpBlockError(e) if (!isNetworkError(e) && !canRotateByProxyHttp) { throw e } lastErr = e logger?.warn?.( `[lepaoOutboundProxy] 出口失败并切换 student=${studentNum} direct=${step.direct} proxyId=${step.proxyId} status=${e?.response?.status || 'NA'}: ${e.message || e}` ) } } throw lastErr || new Error('网络请求失败') } /** * WebVPN(Python)用:与 axios 序列一致,产生 proxy_url * @returns {Promise>} */ async function buildWebVpnAttemptList(createUserUuid, studentNum) { const seq = await buildAttemptSequence(createUserUuid || null, studentNum || null) return seq.map((s) => ({ direct: s.direct, proxyId: s.proxyId, proxyUrl: s.direct ? null : proxyUrlFromPoolRow(s.row) })) } module.exports = { invalidateGlobalCache, getGlobalRow, isNetworkError, isProxyHttpBlockError, isDebugAxiosProxy, isPyOutboundRetryable, axiosExtrasFromPoolRow, proxyUrlFromPoolRow, stepLabel, buildAttemptSequence, withLepaoAccountProxy, buildWebVpnAttemptList, persistAssignedProxy }