Browse Source

✨ feat: 增加代理功能

Pchen0 17 hours ago
parent
commit
780d84ed27

+ 47 - 0
apis/Corn/ProxySync.js

@@ -0,0 +1,47 @@
+const API = require('../../lib/API.js')
+const { BaseStdResponse } = require('../../BaseStdResponse.js')
+const { importFromRemoteUrl, batchCheckProxies, getGlobalOrThrow } = require('../../lib/Lepao/lepaoProxyPoolService')
+
+/**
+ * Corn:拉取全局 import_url 上的代理文本并写入池,随后全表探活。
+ * 建议由 crontab 定时 GET 调用(与 UpdateState 类同,依赖网络隔离)。
+ */
+class ProxySync extends API {
+    constructor() {
+        super()
+        this.noEncrypt()
+        this.setPath('/Corn/ProxySync')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            res.json({ ...BaseStdResponse.OK })
+
+            try {
+                const g = await getGlobalOrThrow()
+                const url = String(g.import_url || '').trim()
+                if (!url) {
+                    this.logger.error('[Corn/ProxySync] import_url 为空')
+                    return
+                }
+                const impr = await importFromRemoteUrl(url, 'cron')
+                this.logger.info(`[Corn/ProxySync] 导入完成 imported=${impr.imported} url=${url}`)
+            } catch (e) {
+                this.logger.error(`[Corn/ProxySync] 导入失败: ${e.stack || e}`)
+            }
+            try {
+                const st = await batchCheckProxies(null, { logger: this.logger })
+                this.logger.info(
+                    `[Corn/ProxySync] 探活完成 total=${st.total} ok=${st.ok} fail=${st.fail} probe=${st.probeUrl}`
+                )
+            } catch (e) {
+                this.logger.error(`[Corn/ProxySync] 探活失败: ${e.stack || e}`)
+            }
+        } catch (e) {
+            this.logger.error(`[Corn/ProxySync]: ${e.stack || e}`)
+        }
+    }
+}
+
+module.exports.ProxySync = ProxySync

+ 43 - 0
apis/Lepao/Proxy/Admin/BatchCheck.js

@@ -0,0 +1,43 @@
+const API = require('../../../../lib/API')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+const { batchCheckProxies } = require('../../../../lib/Lepao/lepaoProxyPoolService')
+
+class BatchCheck extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/BatchCheck')
+        this.setMethod('post')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, ids } = req.body
+        if ([uuid, session].some((v) => v === '' || v === null || v === undefined)) {
+            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 })
+        }
+
+        let idList = ids
+        if (idList != null && !Array.isArray(idList)) {
+            return res.json({ ...BaseStdResponse.ERR, msg: 'ids 须为数组' })
+        }
+        if (Array.isArray(idList)) {
+            idList = idList.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+        }
+
+        try {
+            const stats = await batchCheckProxies(idList?.length ? idList : null, { logger: this.logger })
+            return res.json({ ...BaseStdResponse.OK, data: stats })
+        } catch (e) {
+            return res.json({ ...BaseStdResponse.ERR, msg: e.message || '检测失败' })
+        }
+    }
+}
+
+module.exports.BatchCheck = BatchCheck

+ 40 - 0
apis/Lepao/Proxy/Admin/DeleteProxy.js

@@ -0,0 +1,40 @@
+const API = require('../../../../lib/API')
+const db = require('../../../../plugin/DataBase/db')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+
+class DeleteProxy extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/Delete')
+        this.setMethod('delete')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id } = req.body
+        if ([uuid, session, id].some((v) => v === '' || v === null || v === undefined)) {
+            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 poolId = Number(id)
+        if (!Number.isFinite(poolId) || poolId <= 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+        }
+
+        const r = await db.query('DELETE FROM lepao_proxy_pool WHERE id = ?', [poolId])
+        const affected = r?.affectedRows ?? 0
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: { deleted: affected }
+        })
+    }
+}
+
+module.exports.DeleteProxy = DeleteProxy

+ 35 - 0
apis/Lepao/Proxy/Admin/GlobalGet.js

@@ -0,0 +1,35 @@
+const API = require('../../../../lib/API')
+const db = require('../../../../plugin/DataBase/db')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+
+class GlobalGet extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/Global')
+        this.setMethod('get')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session } = req.query
+        if ([uuid, session].some((v) => v === '' || v === null || v === undefined)) {
+            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 rows = await db.query('SELECT * FROM lepao_proxy_global WHERE id = 1 LIMIT 1')
+        const row = rows?.[0]
+        if (!row) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '全局配置未初始化,请执行 scripts/lepao_proxy_pool.sql' })
+        }
+        return res.json({ ...BaseStdResponse.OK, data: row })
+    }
+}
+
+module.exports.GlobalGet = GlobalGet

+ 35 - 0
apis/Lepao/Proxy/Admin/ImportText.js

@@ -0,0 +1,35 @@
+const API = require('../../../../lib/API')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+const { importFromText } = require('../../../../lib/Lepao/lepaoProxyPoolService')
+
+class ImportText extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/ImportText')
+        this.setMethod('post')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, text } = req.body
+        if ([uuid, session].some((v) => v === '' || v === null || v === undefined)) {
+            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 r = await importFromText(String(text || ''), 'manual')
+            return res.json({ ...BaseStdResponse.OK, data: r })
+        } catch (e) {
+            return res.json({ ...BaseStdResponse.ERR, msg: e.message || '导入失败' })
+        }
+    }
+}
+
+module.exports.ImportText = ImportText

+ 43 - 0
apis/Lepao/Proxy/Admin/ImportUrl.js

@@ -0,0 +1,43 @@
+const API = require('../../../../lib/API')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+const { importFromRemoteUrl, getGlobalOrThrow } = require('../../../../lib/Lepao/lepaoProxyPoolService')
+
+class ImportUrl extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/ImportUrl')
+        this.setMethod('post')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, url } = req.body
+        if ([uuid, session].some((v) => v === '' || v === null || v === undefined)) {
+            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 {
+            let target = String(url || '').trim()
+            if (!target) {
+                const g = await getGlobalOrThrow()
+                target = String(g.import_url || '').trim()
+            }
+            if (!target) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '未配置 import_url' })
+            }
+            const r = await importFromRemoteUrl(target, 'url')
+            return res.json({ ...BaseStdResponse.OK, data: { ...r, usedUrl: target } })
+        } catch (e) {
+            return res.json({ ...BaseStdResponse.ERR, msg: e.message || '从 URL 导入失败' })
+        }
+    }
+}
+
+module.exports.ImportUrl = ImportUrl

+ 114 - 0
apis/Lepao/Proxy/Admin/List.js

@@ -0,0 +1,114 @@
+const API = require('../../../../lib/API')
+const db = require('../../../../plugin/DataBase/db')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+const path = require('path')
+const ipSearcher = require('../../../../plugin/ip2region')
+
+let searcher = null
+function getSearcher() {
+    if (searcher) return searcher
+    searcher = ipSearcher.newWithFileOnly(path.join(__dirname, '../../../../plugin/ip2region/ip2region.xdb'))
+    return searcher
+}
+
+function isIpv4(host) {
+    return /^(\d{1,3}\.){3}\d{1,3}$/.test(String(host || '').trim())
+}
+
+class List extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/List')
+        this.setMethod('get')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, pagesize, current, is_active, host } = req.query
+
+        if ([uuid, session, pagesize, current].some((v) => v === '' || v === null || v === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+        if (isNaN(pagesize) || Number(pagesize) <= 0 || Number(pagesize) > 100) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '参数错误' })
+        }
+        if (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 offset = (Number(current) - 1) * Number(pagesize)
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+
+        if (is_active !== undefined && is_active !== null && String(is_active) !== '') {
+            where.push('p.is_active = ?')
+            params.push(Number(is_active) === 1 ? 1 : 0)
+            countParams.push(Number(is_active) === 1 ? 1 : 0)
+        }
+        if (host) {
+            where.push('p.host LIKE ?')
+            params.push(`%${host}%`)
+            countParams.push(`%${host}%`)
+        }
+
+        const whereSql = where.join(' AND ')
+
+        const listSql = `
+            SELECT
+                p.*,
+                (SELECT COUNT(*) FROM lepao_account a WHERE a.assigned_proxy_id = p.id) AS account_count
+            FROM lepao_proxy_pool p
+            WHERE ${whereSql}
+            ORDER BY p.id DESC
+            LIMIT ? OFFSET ?
+        `
+        params.push(String(pagesize), String(offset))
+
+        const countSql = `
+            SELECT COUNT(*) AS total
+            FROM lepao_proxy_pool p
+            WHERE ${whereSql}
+        `
+
+        const rows = await db.query(listSql, params)
+        const countResult = await db.query(countSql, countParams)
+        if (!rows || !countResult) {
+            return res.json({ ...BaseStdResponse.DATABASE_ERR, msg: '查询失败' })
+        }
+
+        const s = getSearcher()
+        for (const row of rows) {
+            row.ip_location = '未知'
+            if (!isIpv4(row.host)) {
+                row.ip_location = '非IPv4/域名'
+                continue
+            }
+            try {
+                const info = await s.search(row.host)
+                row.ip_location = info?.region || '未知'
+            } catch {
+                row.ip_location = '未知'
+            }
+        }
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows,
+            pagination: {
+                current: Number(current),
+                pagesize: Number(pagesize),
+                total: countResult[0].total
+            }
+        })
+    }
+}
+
+module.exports.List = List

+ 43 - 0
apis/Lepao/Proxy/Admin/SetGlobal.js

@@ -0,0 +1,43 @@
+const API = require('../../../../lib/API')
+const AccessControl = require('../../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../../BaseStdResponse')
+const { patchGlobal } = require('../../../../lib/Lepao/lepaoProxyPoolService')
+
+class SetGlobal extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Lepao/Proxy/SetGlobal')
+        this.setMethod('post')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, random_proxy_enabled, import_url, probe_target_url, check_timeout_ms, check_concurrency } =
+            req.body
+        if ([uuid, session].some((v) => v === '' || v === null || v === undefined)) {
+            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 patch = {}
+        if (random_proxy_enabled !== undefined) patch.random_proxy_enabled = random_proxy_enabled
+        if (import_url !== undefined) patch.import_url = import_url
+        if (probe_target_url !== undefined) patch.probe_target_url = probe_target_url
+        if (check_timeout_ms !== undefined) patch.check_timeout_ms = check_timeout_ms
+        if (check_concurrency !== undefined) patch.check_concurrency = check_concurrency
+
+        try {
+            const r = await patchGlobal(patch)
+            return res.json({ ...BaseStdResponse.OK, data: r })
+        } catch (e) {
+            return res.json({ ...BaseStdResponse.ERR, msg: e.message || '保存失败' })
+        }
+    }
+}
+
+module.exports.SetGlobal = SetGlobal

+ 11 - 7
lib/Lepao/Worker.js

@@ -30,6 +30,7 @@ const {
     markLepaoUnifiedAuthFailed,
     markLepaoUnifiedAuthFailed,
     assertLepaoJwBindingOrThrow
     assertLepaoJwBindingOrThrow
 } = require('./webvpnCookie')
 } = require('./webvpnCookie')
+const { withLepaoAccountProxy } = require('./lepaoOutboundProxy')
 
 
 const Logger = require('../Logger')
 const Logger = require('../Logger')
 
 
@@ -341,16 +342,19 @@ class Worker {
             form.append('data', dataEncrypt(JSON.stringify(raw)))
             form.append('data', dataEncrypt(JSON.stringify(raw)))
 
 
             const postOnce = async () => {
             const postOnce = async () => {
-                return axios.post(
-                    url,
-                    form,
-                    {
+                const execPost = (extraAxios) =>
+                    axios.post(url, form, {
                         headers: buildHeaders(),
                         headers: buildHeaders(),
-                        ...this.axiosProxyConfig(),
+                        ...extraAxios,
                         responseType: 'text',
                         responseType: 'text',
                         transformResponse: [(body) => body]
                         transformResponse: [(body) => body]
-                    }
-                )
+                    })
+                return withLepaoAccountProxy(execPost, {
+                    createUserUuid: ctx?.create_user || null,
+                    studentNum: ctx?.lepaoStudentNum || null,
+                    debugAxiosOpts: this.axiosProxyConfig(),
+                    logger: this.logger
+                })
             }
             }
 
 
             let res = await this.withTimeout(postOnce(), name)
             let res = await this.withTimeout(postOnce(), name)

+ 16 - 6
lib/Lepao/lepaoBeforeRunStateSync.js

@@ -6,6 +6,7 @@ const axios = require('axios')
 const db = require('../../plugin/DataBase/db')
 const db = require('../../plugin/DataBase/db')
 const { URLSearchParams } = require('url')
 const { URLSearchParams } = require('url')
 const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
 const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
+const { withLepaoAccountProxy } = require('./lepaoOutboundProxy')
 
 
 const DEFAULT_USER_AGENT =
 const DEFAULT_USER_AGENT =
     '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'
     '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'
@@ -91,13 +92,22 @@ async function syncLepaoStateViaBeforeRun(opts) {
 
 
     let result
     let result
     try {
     try {
-        const postOnce = () =>
-            axios.post('https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260', form, {
-                headers: buildHeaders(),
-                proxy: false,
-                responseType: 'text',
-                transformResponse: [(b) => b]
+        const postOnce = async () => {
+            const execPost = (extraAxios) =>
+                axios.post('https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260', form, {
+                    headers: buildHeaders(),
+                    timeout: 25000,
+                    ...extraAxios,
+                    responseType: 'text',
+                    transformResponse: [(b) => b]
+                })
+            return withLepaoAccountProxy(execPost, {
+                createUserUuid: ownerUuid || null,
+                studentNum: studentNum || null,
+                debugAxiosOpts: { proxy: false },
+                logger
             })
             })
+        }
 
 
         let apiRes = await postOnce()
         let apiRes = await postOnce()
         try {
         try {

+ 236 - 0
lib/Lepao/lepaoOutboundProxy.js

@@ -0,0 +1,236 @@
+/**
+ * 乐跑账号出站代理:全局开关、粘性分配、网络失败轮换、全失败直连。
+ * 本地调试 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
+}

+ 273 - 0
lib/Lepao/lepaoProxyPoolService.js

@@ -0,0 +1,273 @@
+const axios = require('axios')
+const db = require('../../plugin/DataBase/db')
+const { invalidateGlobalCache } = require('./lepaoOutboundProxy')
+
+function parseProxyLines(text) {
+    const out = []
+    const seen = new Set()
+    const body = String(text || '')
+
+    // 兼容纯文本、多行、HTML(含 <br> / 脚本广告)场景:
+    // 从整段内容中直接抽取 IPv4:port(可带 http(s):// 前缀)。
+    const re = /\b(?:(https?):\/\/)?((?:\d{1,3}\.){3}\d{1,3}):(\d{1,5})\b/gi
+    let m
+    while ((m = re.exec(body)) !== null) {
+        const scheme = (m[1] || 'auto').toLowerCase()
+        const host = m[2]
+        const port = Number(m[3])
+        if (!host || !Number.isFinite(port) || port <= 0 || port > 65535) continue
+        const key = `${host}:${port}`
+        if (seen.has(key)) continue
+        seen.add(key)
+        out.push({ host, port, scheme })
+    }
+    return out
+}
+
+async function getGlobalOrThrow() {
+    const rows = await db.query('SELECT * FROM lepao_proxy_global WHERE id = 1 LIMIT 1')
+    const g = rows?.[0]
+    if (!g) throw new Error('lepao_proxy_global 未初始化')
+    return g
+}
+
+/**
+ * @param {Array<{host:string,port:number,scheme:string}>} list
+ * @param {string} source manual|url|cron
+ */
+async function upsertProxyRows(list, source) {
+    const t = Date.now()
+    let inserted = 0
+    let duplicated = 0
+    for (const item of list) {
+        const exists = await db.query(
+            'SELECT id FROM lepao_proxy_pool WHERE host = ? AND port = ? LIMIT 1',
+            [item.host, item.port]
+        )
+        if (exists && exists.length > 0) {
+            duplicated += 1
+            continue
+        }
+
+        await db.query(
+            `
+            INSERT INTO lepao_proxy_pool (host, port, scheme, is_active, source, created_at, updated_at)
+            VALUES (?, ?, ?, 0, ?, ?, ?)
+            `,
+            [item.host, item.port, item.scheme, source, t, t]
+        )
+        inserted += 1
+    }
+    return { total: list.length, inserted, duplicated }
+}
+
+async function importFromText(rawText, source) {
+    const list = parseProxyLines(rawText || '')
+    if (!list.length) {
+        return { ok: true, imported: 0, message: '无有效代理行(期望 ip:port 或 http(s)://ip:port)' }
+    }
+    const res = await upsertProxyRows(list, source || 'manual')
+    return {
+        ok: true,
+        imported: res.inserted,
+        duplicated: res.duplicated,
+        totalParsed: res.total,
+        message: `解析 ${res.total} 条,新增 ${res.inserted} 条,重复跳过 ${res.duplicated} 条`,
+        ...res
+    }
+}
+
+async function importFromRemoteUrl(url, source) {
+    const u = String(url || '').trim()
+    if (!u) throw new Error('import_url 为空')
+    const res = await axios.get(u, {
+        timeout: 30000,
+        proxy: false,
+        responseType: 'text',
+        transformResponse: [(b) => b],
+        validateStatus: () => true
+    })
+    if (typeof res.data !== 'string') {
+        throw new Error('拉取内容不是文本')
+    }
+    if (res.status >= 400) {
+        throw new Error(`拉取 HTTP ${res.status}`)
+    }
+    return importFromText(res.data, source || 'url')
+}
+
+/**
+ * HEAD/GET 探针,经代理访问 probe_target_url
+ * @returns {Promise<{ ok: boolean, latency_ms?: number, err?: string, scheme?: string }>}
+ */
+async function probeOne(row, probeUrl, timeoutMs) {
+    const tryOnce = async (proto) => {
+        const start = Date.now()
+        await axios.get(probeUrl, {
+            proxy: {
+                protocol: proto,
+                host: row.host,
+                port: Number(row.port)
+            },
+            timeout: timeoutMs,
+            validateStatus: () => true,
+            maxRedirects: 5
+        })
+        return { ok: true, latency_ms: Date.now() - start, scheme: proto }
+    }
+
+    try {
+        const rawScheme = String(row.scheme || 'auto').toLowerCase()
+        if (rawScheme === 'http' || rawScheme === 'https') {
+            return await tryOnce(rawScheme)
+        }
+        // 未指定协议时自动判断:先 http 再 https
+        try {
+            return await tryOnce('http')
+        } catch (e1) {
+            try {
+                return await tryOnce('https')
+            } catch (e2) {
+                return { ok: false, err: (e2 && e2.message) || (e1 && e1.message) || 'probe failed' }
+            }
+        }
+    } catch (e) {
+        return { ok: false, err: e.message || String(e) }
+    }
+}
+
+async function updateProxyCheckResult(poolId, { ok, latency_ms, err, scheme }) {
+    const t = Date.now()
+    const normalizedScheme = scheme && (scheme === 'http' || scheme === 'https') ? scheme : null
+    await db.query(
+        `
+        UPDATE lepao_proxy_pool
+        SET
+            is_active = ?,
+            latency_ms = ?,
+            last_check_at = ?,
+            last_error = ?,
+            scheme = COALESCE(?, scheme),
+            updated_at = ?
+        WHERE id = ?
+        `,
+        [ok ? 1 : 0, ok ? latency_ms : null, t, ok ? null : String(err || '').slice(0, 250), normalizedScheme, t, poolId]
+    )
+}
+
+async function poolRowsByIds(ids) {
+    if (!ids?.length) {
+        const rows = await db.query('SELECT * FROM lepao_proxy_pool')
+        return rows || []
+    }
+    const placeholders = ids.map(() => '?').join(',')
+    const rows = await db.query(`SELECT * FROM lepao_proxy_pool WHERE id IN (${placeholders})`, ids)
+    return rows || []
+}
+
+module.exports.parseProxyLines = parseProxyLines
+module.exports.importFromText = importFromText
+module.exports.importFromRemoteUrl = importFromRemoteUrl
+module.exports.probeOne = probeOne
+module.exports.updateProxyCheckResult = updateProxyCheckResult
+module.exports.poolRowsByIds = poolRowsByIds
+
+/**
+ * @param {number[]} [ids] 为空则检测全表
+ * @param {{ logger?: object }} [opts]
+ */
+module.exports.batchCheckProxies = async function batchCheckProxies(ids, opts = {}) {
+    const g = await getGlobalOrThrow()
+    const probeUrl = String(g.probe_target_url || 'https://www.baidu.com')
+    const timeoutMs = Number(g.check_timeout_ms) || 8000
+    const concurrency = Math.max(1, Math.min(50, Number(g.check_concurrency) || 10))
+    const rows = await poolRowsByIds(ids)
+    const logger = opts.logger
+
+    let ok = 0
+    let fail = 0
+
+    let idx = 0
+    async function worker() {
+        while (idx < rows.length) {
+            const my = idx++
+            const row = rows[my]
+            if (!row) continue
+            const r = await probeOne(row, probeUrl, timeoutMs)
+            await updateProxyCheckResult(row.id, r)
+            if (r.ok) ok += 1
+            else fail += 1
+            if (logger && !r.ok) {
+                logger.warn?.(`[proxyCheck] id=${row.id} ${row.host}:${row.port} ${r.err}`)
+            }
+        }
+    }
+
+    const n = Math.min(concurrency, rows.length || 1)
+    await Promise.all(Array.from({ length: n }, () => worker()))
+
+    return {
+        total: rows.length,
+        ok,
+        fail,
+        probeUrl,
+        timeoutMs,
+        concurrency
+    }
+}
+
+module.exports.getGlobalOrThrow = getGlobalOrThrow
+
+/**
+ * @param {object} patch
+ */
+module.exports.patchGlobal = async function patchGlobal(patch) {
+    const allowed = [
+        'random_proxy_enabled',
+        'import_url',
+        'probe_target_url',
+        'check_timeout_ms',
+        'check_concurrency'
+    ]
+    const normalize = {}
+    if (Object.prototype.hasOwnProperty.call(patch, 'random_proxy_enabled')) {
+        normalize.random_proxy_enabled = Number(patch.random_proxy_enabled) === 1 ? 1 : 0
+    }
+    if (Object.prototype.hasOwnProperty.call(patch, 'import_url')) {
+        const u = String(patch.import_url || '').trim().slice(0, 1024)
+        if (!u) throw new Error('import_url 不能为空')
+        normalize.import_url = u
+    }
+    if (Object.prototype.hasOwnProperty.call(patch, 'probe_target_url')) {
+        const u = String(patch.probe_target_url || '').trim().slice(0, 1024)
+        if (!u) throw new Error('probe_target_url 不能为空')
+        normalize.probe_target_url = u
+    }
+    if (Object.prototype.hasOwnProperty.call(patch, 'check_timeout_ms')) {
+        const n = Number(patch.check_timeout_ms)
+        normalize.check_timeout_ms = Number.isFinite(n) ? Math.max(500, Math.min(120000, Math.floor(n))) : 8000
+    }
+    if (Object.prototype.hasOwnProperty.call(patch, 'check_concurrency')) {
+        const n = Number(patch.check_concurrency)
+        normalize.check_concurrency = Number.isFinite(n) ? Math.max(1, Math.min(50, Math.floor(n))) : 10
+    }
+
+    const sets = []
+    const params = []
+    const t = Date.now()
+    for (const k of allowed) {
+        if (Object.prototype.hasOwnProperty.call(normalize, k)) {
+            sets.push(`${k} = ?`)
+            params.push(normalize[k])
+        }
+    }
+    if (!sets.length) {
+        return { updated: 0 }
+    }
+    sets.push('updated_at = ?')
+    params.push(t)
+    params.push(1)
+    await db.query(`UPDATE lepao_proxy_global SET ${sets.join(', ')} WHERE id = ?`, params)
+    invalidateGlobalCache()
+    return { updated: 1 }
+}

+ 44 - 8
lib/Lepao/webvpnCookie.js

@@ -6,6 +6,7 @@ const axios = require('axios')
 const config = require('../../config.json')
 const config = require('../../config.json')
 const db = require('../../plugin/DataBase/db')
 const db = require('../../plugin/DataBase/db')
 const Redis = require('../../plugin/DataBase/Redis')
 const Redis = require('../../plugin/DataBase/Redis')
+const { buildWebVpnAttemptList, isPyOutboundRetryable, persistAssignedProxy, stepLabel } = require('./lepaoOutboundProxy')
 
 
 const CACHE_PREFIX = 'webvpn_cookie:'
 const CACHE_PREFIX = 'webvpn_cookie:'
 const CACHE_TTL_SEC = 10 * 60
 const CACHE_TTL_SEC = 10 * 60
@@ -132,16 +133,46 @@ async function fetchWebVpnCookieFromPy(username, password, opts = {}) {
         ua = await resolveUserAgentFromLepaoAccount(opts.createUserUuid, opts.lepaoStudentNum)
         ua = await resolveUserAgentFromLepaoAccount(opts.createUserUuid, opts.lepaoStudentNum)
     }
     }
     const url = `${base}/webvpnlogin`
     const url = `${base}/webvpnlogin`
-    const body = { username, password }
-    if (ua) body.user_agent = ua
-    const res = await axios.post(url, body, { timeout: 120000, proxy: false, validateStatus: () => true })
-    const data = res.data
-    if (!data || data.code !== 0 || !data.webvpn_cookie) {
+    const createUserUuid = opts.createUserUuid || null
+    const lepaoStudentNum = opts.lepaoStudentNum || null
+    const logger = opts.logger
+    const attempts = await buildWebVpnAttemptList(createUserUuid, lepaoStudentNum)
+    let lastErr = null
+    for (const att of attempts) {
+        logger?.info?.(
+            `[WebVPN] 登录请求 username=${username} via=${att.direct ? 'DIRECT' : (att.proxyUrl || stepLabel(att))}`
+        )
+        const body = { username, password }
+        if (ua) body.user_agent = ua
+        if (att.proxyUrl) body.proxy_url = att.proxyUrl
+        const res = await axios.post(url, body, { timeout: 120000, proxy: false, validateStatus: () => true })
+        const data = res.data
+        if (data && data.code === 0 && data.webvpn_cookie) {
+            if (createUserUuid && lepaoStudentNum) {
+                if (att.direct) {
+                    await persistAssignedProxy(createUserUuid, lepaoStudentNum, null)
+                } else if (att.proxyId != null) {
+                    await persistAssignedProxy(createUserUuid, lepaoStudentNum, att.proxyId)
+                }
+            }
+            logger?.info?.(
+                `[WebVPN] 登录成功 username=${username} via=${att.direct ? 'DIRECT' : (att.proxyUrl || stepLabel(att))}`
+            )
+            return String(data.webvpn_cookie)
+        }
         const pyMsg = data?.msg || '统一身份认证登录失败'
         const pyMsg = data?.msg || '统一身份认证登录失败'
         const pyCode = typeof data?.code === 'number' ? data.code : undefined
         const pyCode = typeof data?.code === 'number' ? data.code : undefined
-        throw new WebVpnLoginError(pyMsg, { pyCode, pyMsg })
+        const err = new WebVpnLoginError(pyMsg, { pyCode, pyMsg })
+        lastErr = err
+        if (isPyOutboundRetryable(pyCode, pyMsg)) {
+            logger?.warn?.(
+                `[WebVPN] 网络类失败,切换出口 username=${username} via=${att.direct ? 'DIRECT' : (att.proxyUrl || stepLabel(att))}: ${pyMsg}`
+            )
+            continue
+        }
+        throw err
     }
     }
-    return String(data.webvpn_cookie)
+    throw lastErr || new WebVpnLoginError('统一身份认证登录失败', { pyMsg: '' })
 }
 }
 
 
 /**
 /**
@@ -211,7 +242,12 @@ async function getWebVpnCookieHeader(createUserUuid, jwUsername, opts = {}) {
         if (cached) return cached
         if (cached) return cached
     }
     }
     const pwd = await getJwPasswordForWebVpn(createUserUuid, jwUsername)
     const pwd = await getJwPasswordForWebVpn(createUserUuid, jwUsername)
-    const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd, ua ? { userAgent: ua } : {})
+    const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd, {
+        userAgent: ua,
+        createUserUuid,
+        lepaoStudentNum: jwUsername,
+        logger: opts.logger
+    })
     await Redis.set(key, cookie, { EX: CACHE_TTL_SEC })
     await Redis.set(key, cookie, { EX: CACHE_TTL_SEC })
 
 
     await markJwUnifiedAuthSucceeded(createUserUuid, jwUsername)
     await markJwUnifiedAuthSucceeded(createUserUuid, jwUsername)

+ 16 - 7
plugin/Lepao/runforgeSetZoneProbe.js

@@ -12,6 +12,7 @@ const {
     isWebVpnUnifiedAuthCredentialFailure,
     isWebVpnUnifiedAuthCredentialFailure,
     markLepaoUnifiedAuthFailed
     markLepaoUnifiedAuthFailed
 } = require('../../lib/Lepao/webvpnCookie')
 } = require('../../lib/Lepao/webvpnCookie')
+const { withLepaoAccountProxy } = require('../../lib/Lepao/lepaoOutboundProxy')
 
 
 const BASE_URL = 'https://lepao.ctbu.edu.cn/v3/api.php'
 const BASE_URL = 'https://lepao.ctbu.edu.cn/v3/api.php'
 
 
@@ -108,14 +109,22 @@ async function probeSetZone(p) {
         ...(webvpnCookie ? { Cookie: webvpnCookie } : {})
         ...(webvpnCookie ? { Cookie: webvpnCookie } : {})
     })
     })
 
 
-    const postOnce = () =>
-        axios.post(`${BASE_URL}/Run/setRunZone`, form, {
-            headers: buildHeaders(),
-            timeout: 20000,
-            proxy: false,
-            responseType: 'text',
-            transformResponse: [(b) => b]
+    const postOnce = async () => {
+        const execPost = (extraAxios) =>
+            axios.post(`${BASE_URL}/Run/setRunZone`, form, {
+                headers: buildHeaders(),
+                timeout: 20000,
+                ...extraAxios,
+                responseType: 'text',
+                transformResponse: [(b) => b]
+            })
+        return withLepaoAccountProxy(execPost, {
+            createUserUuid: createUser || null,
+            studentNum: student_num || null,
+            debugAxiosOpts: { proxy: false },
+            logger: undefined
         })
         })
+    }
 
 
     let res = await postOnce()
     let res = await postOnce()
     let result
     let result

+ 71 - 0
scripts/lepao_proxy_pool.sql

@@ -0,0 +1,71 @@
+-- 乐跑出站代理池 + 全局配置 + lepao_account.assigned_proxy_id
+-- MySQL:请使用有 DDL 权限的账号执行;重复执行会使用 INFORMATION_SCHEMA 跳过已存在对象。
+
+CREATE TABLE IF NOT EXISTS lepao_proxy_pool (
+    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    host VARCHAR(128) NOT NULL,
+    port INT UNSIGNED NOT NULL,
+    scheme VARCHAR(16) NOT NULL DEFAULT 'http',
+    is_active TINYINT NOT NULL DEFAULT 1,
+    latency_ms INT UNSIGNED DEFAULT NULL,
+    last_check_at BIGINT DEFAULT NULL,
+    last_error VARCHAR(255) DEFAULT NULL,
+    source VARCHAR(32) NOT NULL DEFAULT 'manual',
+    remark VARCHAR(255) DEFAULT NULL,
+    created_at BIGINT NOT NULL,
+    updated_at BIGINT NOT NULL,
+    PRIMARY KEY (id),
+    UNIQUE KEY uniq_host_port (host, port),
+    KEY idx_is_active (is_active),
+    KEY idx_last_check_at (last_check_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS lepao_proxy_global (
+    id TINYINT NOT NULL,
+    random_proxy_enabled TINYINT NOT NULL DEFAULT 0,
+    import_url VARCHAR(1024) NOT NULL,
+    probe_target_url VARCHAR(1024) NOT NULL DEFAULT 'https://www.baidu.com',
+    check_timeout_ms INT UNSIGNED NOT NULL DEFAULT 8000,
+    check_concurrency INT UNSIGNED NOT NULL DEFAULT 10,
+    created_at BIGINT NOT NULL,
+    updated_at BIGINT NOT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO lepao_proxy_global (
+    id,
+    random_proxy_enabled,
+    import_url,
+    probe_target_url,
+    check_timeout_ms,
+    check_concurrency,
+    created_at,
+    updated_at
+)
+SELECT
+    1,
+    0,
+    'http://api.89ip.cn/tqdl.html?api=1&num=60&port=&address=%E9%87%8D%E5%BA%86&isp=',
+    'https://www.baidu.com',
+    8000,
+    10,
+    UNIX_TIMESTAMP() * 1000,
+    UNIX_TIMESTAMP() * 1000
+WHERE NOT EXISTS (
+    SELECT 1 FROM lepao_proxy_global WHERE id = 1
+);
+
+-- 下面三条为“首次执行”版本,兼容 MySQL 5.7/8.0。
+-- 若提示已存在(重复列/索引/外键),可忽略对应报错继续执行后续语句。
+ALTER TABLE lepao_account
+    ADD COLUMN assigned_proxy_id BIGINT UNSIGNED NULL;
+
+ALTER TABLE lepao_account
+    ADD INDEX idx_assigned_proxy_id (assigned_proxy_id);
+
+ALTER TABLE lepao_account
+    ADD CONSTRAINT fk_lepao_account_proxy_id
+    FOREIGN KEY (assigned_proxy_id)
+    REFERENCES lepao_proxy_pool(id)
+    ON DELETE SET NULL
+    ON UPDATE RESTRICT;

+ 0 - 2
sql/drop_lepao_jw_username_column.sql

@@ -1,2 +0,0 @@
--- 仅在 lepao_account 表中已存在 jw_username 列时执行(否则会报 Unknown column 或语法错误视版本而定)。
-ALTER TABLE lepao_account DROP COLUMN jw_username;

+ 0 - 4
sql/lepao_webvpn_ddl_optional.sql

@@ -1,4 +0,0 @@
--- WebVPN 乐跑:教务登录名与学号一致,不需要给 lepao_account 加字段。
--- 教务密码写在已有表 jw_account(username = 学号),无需新建表。
---
--- 若曾误加 jw_username 列,请执行:sql/drop_lepao_jw_username_column.sql