| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- /**
- * 乐跑账号出站代理:全局开关、粘性分配、网络失败轮换、全失败直连。
- * 本地调试 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<Array<{ direct: boolean, proxyId: number|null, row: object|null }>>}
- */
- 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<T>} postFn
- * @param {{ createUserUuid?: string|null, studentNum?: string|null, debugAxiosOpts: object, logger?: object }} ctx
- * @returns {Promise<T>}
- */
- 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<Array<{ direct: boolean, proxyId: number|null, proxyUrl: string|null }>>}
- */
- 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
- }
|