lepaoOutboundProxy.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. /**
  2. * 乐跑账号出站代理:全局开关、粘性分配、网络失败轮换、全失败直连。
  3. * 本地调试 LEPAO_DEBUG_PROXY=1 时由调用方 axios 配置优先,不走账号代理池。
  4. */
  5. const db = require('../../plugin/DataBase/db')
  6. const GLOBAL_CACHE_MS = 5000
  7. let globalCache = { at: 0, row: null }
  8. function invalidateGlobalCache() {
  9. globalCache = { at: 0, row: null }
  10. }
  11. function nowMs() {
  12. return Date.now()
  13. }
  14. async function getGlobalRow() {
  15. const t = nowMs()
  16. if (globalCache.row && t - globalCache.at < GLOBAL_CACHE_MS) {
  17. return globalCache.row
  18. }
  19. const rows = await db.query('SELECT * FROM lepao_proxy_global WHERE id = 1 LIMIT 1')
  20. const row = rows?.[0] || null
  21. globalCache = { at: t, row }
  22. return row
  23. }
  24. function isNetworkError(err) {
  25. if (!err) return false
  26. if (err.code && ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED'].includes(err.code)) {
  27. return true
  28. }
  29. if (err.isAxiosError && !err.response) return true
  30. const msg = String(err.message || '').toLowerCase()
  31. return msg.includes('timeout') || msg.includes('network')
  32. }
  33. /**
  34. * 仅在“当前出口是代理”时,把这类 HTTP 状态码视为代理被封/限流/网关异常,允许切换出口。
  35. * 直连请求不使用该规则,避免把真实业务 4xx/5xx 当成代理问题。
  36. */
  37. function isProxyHttpBlockError(err) {
  38. if (!err || !err.response) return false
  39. const status = Number(err.response.status)
  40. if (!Number.isFinite(status)) return false
  41. // 404 在代理场景下常见于代理网关/中间层返回(并非目标业务接口真实 404),允许轮换
  42. if (status === 403 || status === 404 || status === 407 || status === 409 || status === 429) return true
  43. if (status >= 500 && status <= 599) return true
  44. return false
  45. }
  46. function isDebugAxiosProxy(debugAxiosOpts) {
  47. return Boolean(debugAxiosOpts && debugAxiosOpts.proxy && typeof debugAxiosOpts.proxy === 'object')
  48. }
  49. function axiosExtrasFromPoolRow(row) {
  50. if (!row) return { proxy: false }
  51. const protocol = String(row.scheme || 'http').toLowerCase()
  52. return {
  53. proxy: {
  54. protocol,
  55. host: row.host,
  56. port: Number(row.port)
  57. }
  58. }
  59. }
  60. function proxyUrlFromPoolRow(row) {
  61. if (!row) return null
  62. const protocol = String(row.scheme || 'http').toLowerCase()
  63. return `${protocol}://${row.host}:${Number(row.port)}`
  64. }
  65. function stepLabel(step) {
  66. if (!step || step.direct) return 'DIRECT'
  67. const protocol = String(step.row?.scheme || 'http').toLowerCase()
  68. return `${protocol}://${step.row?.host}:${Number(step.row?.port)}#${step.proxyId}`
  69. }
  70. /**
  71. * Python webvpn 返回是否应按「换代理/直连」重试
  72. * @param {number|undefined} pyCode
  73. * @param {string|undefined} pyMsg
  74. */
  75. function isPyOutboundRetryable(pyCode, pyMsg) {
  76. if (pyCode === 502) return true
  77. const m = String(pyMsg || '')
  78. if (pyCode === 500 && (m.includes('网络错误') || m.includes('网络连接') || m.includes('代理'))) return true
  79. return false
  80. }
  81. async function persistAssignedProxy(createUserUuid, studentNum, proxyId) {
  82. if (!createUserUuid || !studentNum) return
  83. const pid = proxyId == null ? null : Number(proxyId)
  84. await db.query(
  85. 'UPDATE lepao_account SET assigned_proxy_id = ? WHERE student_num = ? AND create_user = ?',
  86. [pid, studentNum, createUserUuid]
  87. )
  88. }
  89. /**
  90. * 返回活跃代理及绑定账号数,用于排序
  91. */
  92. async function loadActivePoolWithBindCount() {
  93. const rows = await db.query(`
  94. SELECT
  95. p.*,
  96. (SELECT COUNT(*) FROM lepao_account a WHERE a.assigned_proxy_id = p.id) AS bind_cnt
  97. FROM lepao_proxy_pool p
  98. WHERE p.is_active = 1
  99. `)
  100. return Array.isArray(rows) ? rows : []
  101. }
  102. /**
  103. * @returns {Promise<Array<{ direct: boolean, proxyId: number|null, row: object|null }>>}
  104. */
  105. async function buildAttemptSequence(createUserUuid, studentNum) {
  106. const directOnly = [{ direct: true, proxyId: null, row: null }]
  107. if (!createUserUuid || !studentNum) {
  108. return directOnly
  109. }
  110. const g = await getGlobalRow()
  111. if (!g || Number(g.random_proxy_enabled) !== 1) {
  112. return directOnly
  113. }
  114. const pool = await loadActivePoolWithBindCount()
  115. if (!pool.length) {
  116. return directOnly
  117. }
  118. const accRows = await db.query(
  119. 'SELECT assigned_proxy_id FROM lepao_account WHERE student_num = ? AND create_user = ? LIMIT 1',
  120. [studentNum, createUserUuid]
  121. )
  122. const assignedId = accRows?.[0]?.assigned_proxy_id != null ? Number(accRows[0].assigned_proxy_id) : null
  123. const tries = []
  124. const seen = new Set()
  125. if (assignedId) {
  126. const sticky = pool.find((r) => Number(r.id) === assignedId)
  127. if (sticky) {
  128. tries.push({ direct: false, proxyId: sticky.id, row: sticky })
  129. seen.add(Number(sticky.id))
  130. }
  131. }
  132. const rest = pool.filter((r) => !seen.has(Number(r.id)))
  133. rest.sort((a, b) => {
  134. const d = Number(a.bind_cnt) - Number(b.bind_cnt)
  135. if (d !== 0) return d
  136. return Math.random() - 0.5
  137. })
  138. for (const r of rest) {
  139. tries.push({ direct: false, proxyId: r.id, row: r })
  140. }
  141. tries.push({ direct: true, proxyId: null, row: null })
  142. return tries
  143. }
  144. /**
  145. * @template T
  146. * @param {(axiosExtra: object) => Promise<T>} postFn
  147. * @param {{ createUserUuid?: string|null, studentNum?: string|null, debugAxiosOpts: object, logger?: object }} ctx
  148. * @returns {Promise<T>}
  149. */
  150. async function withLepaoAccountProxy(postFn, ctx) {
  151. const { createUserUuid, studentNum, debugAxiosOpts, logger } = ctx
  152. if (isDebugAxiosProxy(debugAxiosOpts)) {
  153. return postFn(debugAxiosOpts)
  154. }
  155. const seq = await buildAttemptSequence(createUserUuid || null, studentNum || null)
  156. let lastErr
  157. for (const step of seq) {
  158. const ax = step.direct ? { proxy: false } : axiosExtrasFromPoolRow(step.row)
  159. logger?.info?.(
  160. `[lepaoOutboundProxy] 出站请求 student=${studentNum || 'unknown'} via=${stepLabel(step)}`
  161. )
  162. try {
  163. const res = await postFn(ax)
  164. if (createUserUuid && studentNum) {
  165. if (step.direct) {
  166. await persistAssignedProxy(createUserUuid, studentNum, null)
  167. } else {
  168. await persistAssignedProxy(createUserUuid, studentNum, step.proxyId)
  169. }
  170. }
  171. logger?.info?.(
  172. `[lepaoOutboundProxy] 出站成功 student=${studentNum || 'unknown'} via=${stepLabel(step)}`
  173. )
  174. return res
  175. } catch (e) {
  176. const canRotateByProxyHttp = !step.direct && isProxyHttpBlockError(e)
  177. if (!isNetworkError(e) && !canRotateByProxyHttp) {
  178. throw e
  179. }
  180. lastErr = e
  181. logger?.warn?.(
  182. `[lepaoOutboundProxy] 出口失败并切换 student=${studentNum} direct=${step.direct} proxyId=${step.proxyId} status=${e?.response?.status || 'NA'}: ${e.message || e}`
  183. )
  184. }
  185. }
  186. throw lastErr || new Error('网络请求失败')
  187. }
  188. /**
  189. * WebVPN(Python)用:与 axios 序列一致,产生 proxy_url
  190. * @returns {Promise<Array<{ direct: boolean, proxyId: number|null, proxyUrl: string|null }>>}
  191. */
  192. async function buildWebVpnAttemptList(createUserUuid, studentNum) {
  193. const seq = await buildAttemptSequence(createUserUuid || null, studentNum || null)
  194. return seq.map((s) => ({
  195. direct: s.direct,
  196. proxyId: s.proxyId,
  197. proxyUrl: s.direct ? null : proxyUrlFromPoolRow(s.row)
  198. }))
  199. }
  200. module.exports = {
  201. invalidateGlobalCache,
  202. getGlobalRow,
  203. isNetworkError,
  204. isProxyHttpBlockError,
  205. isDebugAxiosProxy,
  206. isPyOutboundRetryable,
  207. axiosExtrasFromPoolRow,
  208. proxyUrlFromPoolRow,
  209. stepLabel,
  210. buildAttemptSequence,
  211. withLepaoAccountProxy,
  212. buildWebVpnAttemptList,
  213. persistAssignedProxy
  214. }