Browse Source

✨ feat: 更新代理请求方式

Pchen. 1 month ago
parent
commit
259ea27f39

+ 0 - 102
apis/Corn/ProxySelfCheck.js

@@ -1,102 +0,0 @@
-const axios = require('axios')
-const config = require('../../config.json')
-const API = require('../../lib/API')
-const { BaseStdResponse } = require('../../BaseStdResponse')
-const QgProxyManager = require('../../lib/Lepao/QgProxyManager')
-const { buildAxiosOutboundConfig } = require('../../lib/Lepao/outboundAxiosConfig')
-
-function resolveSelfEchoUrl() {
-    const base = String(config.url || '').trim()
-    if (!base) return `http://127.0.0.1:${config.port}/Corn/ProxySelfIpEcho`
-    return `${base.replace(/\/+$/, '')}/Corn/ProxySelfIpEcho`
-}
-
-class ProxySelfCheck extends API {
-    constructor() {
-        super()
-        this.noEncrypt()
-        this.setPath('/Corn/ProxySelfCheck')
-        this.setMethod('GET')
-    }
-
-    async onRequest(req, res) {
-        try {
-            const qgOn = await QgProxyManager.isOutboundProxyEnabled()
-            if (!qgOn) {
-                await QgProxyManager.recordLog({
-                    event: 'proxy_self_check_skip',
-                    detail: { reason: 'proxy_disabled' }
-                })
-                return res.json({
-                    ...BaseStdResponse.OK,
-                    data: { skipped: true, reason: 'proxy_disabled' }
-                })
-            }
-
-            const frag = await QgProxyManager.getOutboundAxiosFragment({ forceRefresh: false })
-            if (frag.proxy === false) {
-                await QgProxyManager.recordLog({
-                    event: 'proxy_self_check_skip',
-                    detail: { reason: 'no_proxy_available' }
-                })
-                return res.json({
-                    ...BaseStdResponse.OK,
-                    data: { skipped: true, reason: 'no_proxy_available' }
-                })
-            }
-
-            const outbound = buildAxiosOutboundConfig(frag)
-            const echoUrl = resolveSelfEchoUrl()
-            const rsp = await axios.get(echoUrl, {
-                timeout: 15000,
-                validateStatus: () => true,
-                ...outbound
-            })
-
-            const body = rsp.data || {}
-            const now = Date.now()
-            const cached = await QgProxyManager.getCachedParsed()
-            const proxyIp = body?.data?.ip || null
-
-            await QgProxyManager.recordLog({
-                event: 'proxy_self_check',
-                server: cached?.server || `${frag.proxy.host}:${frag.proxy.port}`,
-                deadline: cached?.deadline || null,
-                detail: {
-                    code: body?.code,
-                    http_status: rsp.status,
-                    target: echoUrl,
-                    proxy_ip: proxyIp,
-                    x_forwarded_for: body?.data?.x_forwarded_for || '',
-                    checked_at: now
-                }
-            })
-
-            return res.json({
-                ...BaseStdResponse.OK,
-                data: {
-                    proxy_ip: proxyIp,
-                    http_status: rsp.status,
-                    target: echoUrl
-                }
-            })
-        } catch (e) {
-            const msg = e?.message || String(e)
-            await QgProxyManager.recordLog({
-                event: 'proxy_self_check_fail',
-                detail: {
-                    message: msg,
-                    code: e?.code,
-                    status: e?.response?.status
-                }
-            })
-            this.logger?.error?.(`[ProxySelfCheck] ${e?.stack || e}`)
-            return res.json({
-                ...BaseStdResponse.ERR,
-                msg: `代理自检失败: ${msg}`
-            })
-        }
-    }
-}
-
-module.exports.ProxySelfCheck = ProxySelfCheck

+ 0 - 39
apis/Corn/ProxySelfIpEcho.js

@@ -1,39 +0,0 @@
-const API = require('../../lib/API')
-const { BaseStdResponse } = require('../../BaseStdResponse')
-
-function getClientIp(req) {
-    let ip = null
-    if (req.headers['x-forwarded-for']) {
-        ip = String(req.headers['x-forwarded-for']).split(',')[0].trim()
-    } else if (req.headers['x-real-ip']) {
-        ip = String(req.headers['x-real-ip']).trim()
-    } else {
-        ip = req.connection?.remoteAddress || req.socket?.remoteAddress || ''
-    }
-
-    if (String(ip).startsWith('::ffff:')) ip = String(ip).replace('::ffff:', '')
-    return ip || '0.0.0.0'
-}
-
-class ProxySelfIpEcho extends API {
-    constructor() {
-        super()
-        this.noEncrypt()
-        this.setPath('/Corn/ProxySelfIpEcho')
-        this.setMethod('GET')
-    }
-
-    async onRequest(req, res) {
-        return res.json({
-            ...BaseStdResponse.OK,
-            data: {
-                ip: getClientIp(req),
-                x_forwarded_for: req.headers['x-forwarded-for'] || '',
-                x_real_ip: req.headers['x-real-ip'] || '',
-                remote_address: req.connection?.remoteAddress || req.socket?.remoteAddress || ''
-            }
-        })
-    }
-}
-
-module.exports.ProxySelfIpEcho = ProxySelfIpEcho

+ 34 - 0
apis/JW/AdminGetAccount.js

@@ -0,0 +1,34 @@
+const API = require('../../lib/API.js')
+const db = require('../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../BaseStdResponse.js')
+const AccessControl = require('../../lib/AccessControl')
+
+class AdminGetAccount extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/JW/Account')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, create_user } = req.query
+
+        if ([uuid, session, create_user].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        try {
+            const rows = await AccessControl.listVerifiedJwAccounts(create_user)
+            return res.json({ ...BaseStdResponse.OK, data: rows })
+        } catch (err) {
+            this.logger.error(`管理员获取统一身份认证账号失败:${err.stack || err}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '获取统一身份认证账号失败' })
+        }
+    }
+}
+
+module.exports.AdminGetAccount = AdminGetAccount

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

@@ -1,89 +0,0 @@
-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]
-            )
-            await db.query(
-                `INSERT INTO lepao_proxy_project_settings (scope_key, proxy_enabled, updated_at)
-                 VALUES (?, ?, ?)
-                 ON DUPLICATE KEY UPDATE proxy_enabled = VALUES(proxy_enabled), updated_at = VALUES(updated_at)`,
-                [QgProxyManager.getProjectKey(), enabled, 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,
-                    project_scope_key: QgProxyManager.getProjectKey(),
-                    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

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

@@ -1,84 +0,0 @@
-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

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

@@ -1,74 +0,0 @@
-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

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

@@ -1,47 +0,0 @@
-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

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

@@ -1,89 +0,0 @@
-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: {
-                    project_scope_key: snap.project_scope_key,
-                    proxy_enabled: snap.proxy_enabled,
-                    proxy_enabled_default: snap.proxy_enabled_default,
-                    project_proxy_updated_at: snap.project_proxy_updated_at,
-                    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

+ 6 - 9
apis/QK/Task/Task.js

@@ -20,8 +20,8 @@ class GetTask extends API {
 
     async onRequest(req, res) {
         try {
-            const { uuid, status, student_num, name, batch_id } = req.query
-            const list = await scheduler.listUserTasks(uuid, { status, student_num, name, batch_id })
+            const { uuid, status, student_num, name, batch_id, course_name, course } = req.query
+            const list = await scheduler.listUserTasks(uuid, { status, student_num, name, batch_id, course_name, course })
             return res.json({ ...BaseStdResponse.OK, data: list })
         } catch (err) {
             this.logger.error(`获取抢课任务失败:${err.stack || err}`)
@@ -64,12 +64,9 @@ class SaveTask extends API {
 
     async onRequest(req, res) {
         try {
-            const { uuid, id, name, batch_id, student_num, password } = req.body
-            if ([uuid, batch_id, student_num].some(value => value === '' || value === null || value === undefined)) {
-                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
-            }
-            if (!id && !password) {
-                return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '新建任务需要填写教务密码' })
+            const { uuid, id, batch_id, jw_account_id } = req.body
+            if ([uuid, batch_id, jw_account_id].some(value => value === '' || value === null || value === undefined)) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER, msg: '请选择选课批次和统一身份认证账号' })
             }
             if (id) {
                 await scheduler.updateTask(uuid, id, req.body)
@@ -77,7 +74,7 @@ class SaveTask extends API {
                 return res.json({ ...BaseStdResponse.OK })
             }
             const taskId = await scheduler.createTask(uuid, req.body)
-            this.logger.info(`[QK][API][SaveTask] 用户创建任务 uuid=${uuid} taskId=${taskId} student_num=${student_num}`)
+            this.logger.info(`[QK][API][SaveTask] 用户创建任务 uuid=${uuid} taskId=${taskId} jw_account_id=${jw_account_id}`)
             return res.json({ ...BaseStdResponse.OK, data: { id: taskId } })
         } catch (err) {
             this.logger.error(`保存抢课任务失败:${err.stack || err}`)

+ 6 - 8
config.example.json

@@ -1,7 +1,12 @@
 {
     "port": 30003,
     "serverRole": "all",
-    "qgProxyScope": "YOUR_DEPLOYMENT_KEY",
+    "proxyForwardServer": {
+        "url": "http://127.0.0.1:30010",
+        "enabled": true,
+        "timeout": 120000
+    },
+    "server": "YOUR_SERVER_LABEL",
     "database": {
         "host": "YOUR_MYSQL_HOST",
         "database": "YOUR_DB_NAME",
@@ -71,7 +76,6 @@
     "url": "https://your-lepao-api.example.com",
     "url2": "http://127.0.0.1:30004",
     "url3": "http://127.0.0.1:30001",
-    "runpy": "http://127.0.0.1:58000/api",
     "pay": {
         "url": "https://pay.example.com",
         "pid": 0,
@@ -86,12 +90,6 @@
         "return_url": "https://your-site.example/uniLogin/loginSuccess",
         "uni_return_url": "https://m.your-site.example/#/pages/login/login"
     },
-    "qgChannelProxy": {
-        "extractKey": "YOUR_EXTRACT_KEY",
-        "authUser": "YOUR_AUTH_USER",
-        "authPassword": "YOUR_AUTH_PASSWORD"
-    },
-    "server": "YOUR_SERVER_LABEL",
     "onebotv11": {
         "enabled": false,
         "transport": "reverse_ws",

+ 19 - 0
lib/AccessControl.js

@@ -281,6 +281,25 @@ class AccessControl {
             return false
         return rows[0]?.password
     }
+
+    async getVerifiedJwAccount(uuid, accountId) {
+        const id = Number(accountId)
+        if (!id) return null
+        const rows = await db.query(
+            'SELECT id, username, password, realname FROM jw_account WHERE id = ? AND create_user = ? AND state = 1',
+            [id, uuid]
+        )
+        if (!rows?.length) return null
+        return rows[0]
+    }
+
+    async listVerifiedJwAccounts(uuid) {
+        const rows = await db.query(
+            'SELECT id, username, realname, deptName, className FROM jw_account WHERE create_user = ? AND state = 1 ORDER BY create_time DESC',
+            [uuid]
+        )
+        return rows || []
+    }
 }
 
 module.exports = new AccessControl();

+ 0 - 534
lib/Lepao/QgProxyManager.js

@@ -1,534 +0,0 @@
-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_POOL_URL = 'https://share.proxy.qg.net/pool'
-const QG_RESOURCES_URL = 'https://share.proxy.qg.net/resources'
-const REDIS_CURRENT = 'lepao:qg:current'
-const REDIS_LOCK = 'lepao:qg:fetch_lock'
-/** 隧道池入口地址缓存时长(服务商后台自动换出口,不依赖本地 deadline 刷新) */
-const REDIS_SERVER_TTL_SEC = 6 * 60 * 60
-/** 仅覆盖单次 /get(含 axios 超时),避免长持锁阻塞其它任务 */
-const LOCK_TTL_SEC = 45
-const LOCK_WAIT_ROUNDS = 40
-const LOCK_WAIT_MS = 150
-
-let warnedTlsRejectUnauthorized = false
-let projectSettingsTableEnsured = false
-
-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(),
-        tunnelServer: String(q.tunnelServer || q.server || '').trim()
-    }
-}
-
-function hasExtractCredentials() {
-    const { extractKey } = getQgConfig()
-    return extractKey.length > 0
-}
-
-function hasProxyAuth() {
-    const { authUser, authPassword } = getQgConfig()
-    return authUser.length > 0 && authPassword.length > 0
-}
-
-function hasTunnelServer() {
-    const { tunnelServer } = getQgConfig()
-    return tunnelServer.length > 0
-}
-
-function normalizeProjectKey(raw) {
-    return String(raw || '')
-        .trim()
-        .replace(/[^\w\u4e00-\u9fa5:.-]/g, '_')
-        .slice(0, 128)
-}
-
-function getProjectKey() {
-    return (
-        normalizeProjectKey(process.env.RUNFORGE_PROXY_SCOPE) ||
-        normalizeProjectKey(config.qgProxyScope) ||
-        normalizeProjectKey(config.qgChannelProxy?.scopeKey) ||
-        normalizeProjectKey(config.server) ||
-        normalizeProjectKey(config.port) ||
-        'default'
-    )
-}
-
-async function ensureProjectSettingsTable() {
-    if (projectSettingsTableEnsured) return
-    await db.query(
-        `CREATE TABLE IF NOT EXISTS lepao_proxy_project_settings (
-            scope_key VARCHAR(128) NOT NULL PRIMARY KEY,
-            proxy_enabled TINYINT NOT NULL,
-            updated_at BIGINT NOT NULL
-        )`
-    )
-    projectSettingsTableEnsured = true
-}
-
-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]
-    )
-    await ensureProjectSettingsTable()
-    await db.query(
-        `INSERT IGNORE INTO lepao_proxy_project_settings (scope_key, proxy_enabled, updated_at)
-         SELECT ?, proxy_enabled, ? FROM lepao_proxy_settings WHERE id = 1`,
-        [getProjectKey(), 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`
-    )
-    const row = rows?.[0] || null
-    if (!row) return null
-
-    const projectRows = await db.query(
-        `SELECT proxy_enabled, updated_at FROM lepao_proxy_project_settings WHERE scope_key = ? LIMIT 1`,
-        [getProjectKey()]
-    )
-    const project = projectRows?.[0] || null
-    return {
-        ...row,
-        project_scope_key: getProjectKey(),
-        project_proxy_enabled: project?.proxy_enabled ?? row.proxy_enabled,
-        project_updated_at: project?.updated_at ?? row.updated_at
-    }
-}
-
-function getProjectProxyEnabledFromSettings(settings) {
-    if (!settings) return false
-    return Number(settings.project_proxy_enabled) === 1
-}
-
-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
-}
-
-function isAreaCodeLike(s) {
-    return /^\d{4,9}$/.test(String(s || '').trim())
-}
-
-function buildTunnelProxyOpts(settings) {
-    const { tunnelServer, authUser, authPassword } = getQgConfig()
-    if (!tunnelServer) return null
-    const base = axiosProxyOptsFromServer(tunnelServer, false)
-    if (!base.proxy) return null
-
-    if (authUser && authPassword) {
-        let password = authPassword
-        const area = String(settings?.area || '').trim()
-        const areaEx = String(settings?.area_ex || '').trim()
-        /**
-         * 参考隧道代理接入:普通模式指定地区通过 user:password:A<area>@server。
-         * 仅当 area 为单个纯数字编码且未配置 area_ex 时尝试附加,避免把旧的多地区逗号表达式拼坏。
-         */
-        if (area && !areaEx && isAreaCodeLike(area)) {
-            password = `${password}:A${area}`
-        }
-        base.proxy.auth = { username: authUser, password }
-    }
-
-    return base
-}
-
-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 Date.now() < ms
-    const fetchedAt = Number(parsed.fetchedAt || 0)
-    if (!Number.isFinite(fetchedAt) || fetchedAt <= 0) return true
-    return Date.now() - fetchedAt < REDIS_SERVER_TTL_SEC * 1000
-}
-
-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 再试(见青果通道提取说明)。REQUEST_LIMIT_EXCEEDED 再刷 /get 会恶化限流,不在此列。 */
-const RETRYABLE_EXTRACT_CODES = new Set([
-    'NO_AVAILABLE_CHANNEL',
-    '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_POOL_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
-}
-
-function warnIfTlsVerifyDisabled() {
-    if (warnedTlsRejectUnauthorized) return
-    if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
-        warnedTlsRejectUnauthorized = true
-        logger.warn(
-            '[QgProxy] 检测到 NODE_TLS_REJECT_UNAUTHORIZED=0,将关闭 TLS 证书校验,存在中间人风险;生产环境建议移除此环境变量。'
-        )
-    }
-}
-
-/**
- * 通道提取:[查询资源地区](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() && !(hasTunnelServer() && hasProxyAuth())) return false
-    const row = await loadSettings()
-    return getProjectProxyEnabledFromSettings(row)
-}
-
-/**
- * 对外:得到可合并进 axios 的代理段;未启用则 { proxy: false }。
- * 隧道池模式:服务商后台自动切换出口,本地仅缓存入口 server,不做频繁刷新。
- * @param {{ forceRefresh?: boolean }} opt
- */
-async function getOutboundAxiosFragment(opt = {}) {
-    const forceRefresh = opt.forceRefresh === true
-    warnIfTlsVerifyDisabled()
-    if (!hasExtractCredentials() && !(hasTunnelServer() && hasProxyAuth())) return { proxy: false }
-
-    const settings = await loadSettings()
-    if (!getProjectProxyEnabledFromSettings(settings)) return { proxy: false }
-
-    // 隧道代理模式:直接使用隧道入口地址,不需要 /pool 提取。
-    const tunnelFrag = buildTunnelProxyOpts(settings)
-    if (tunnelFrag?.proxy) {
-        const payload = {
-            server: `${tunnelFrag.proxy.host}:${tunnelFrag.proxy.port}`,
-            deadline: '',
-            deadlineMs: null,
-            proxyIp: null,
-            requestId: null,
-            fetchedAt: Date.now(),
-            mode: 'tunnel'
-        }
-        await Redis.set(REDIS_CURRENT, JSON.stringify(payload), { EX: Math.max(60, REDIS_SERVER_TTL_SEC) })
-        return tunnelFrag
-    }
-
-    const cachedFast = await getCachedParsed()
-    if (cacheStillValid(cachedFast)) {
-        if (forceRefresh) {
-            logger.info(`[QgProxy] 隧道池忽略 forceRefresh,复用入口 server=${cachedFast.server}`)
-        }
-        return axiosProxyOptsFromServer(cachedFast.server, hasProxyAuth())
-    }
-    if (cachedFast?.server) {
-        logger.info(`[QgProxy] 缓存入口失效,将重新提取。原server=${cachedFast.server}`)
-    }
-
-    const maxAttempts = 5
-    let lastErr
-    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
-        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 }
-        }
-
-        let shouldBackoff = false
-        try {
-            {
-                const cached = await getCachedParsed()
-                if (cacheStillValid(cached)) {
-                    return axiosProxyOptsFromServer(cached.server, hasProxyAuth())
-                }
-            }
-
-            logger.info(`[QgProxy] 调用青果 /pool 提取代理资源... forceRefresh=${forceRefresh}`)
-            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 = REDIS_SERVER_TTL_SEC
-
-            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.max(60, 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 axiosProxyOptsFromServer(server, hasProxyAuth())
-        } catch (e) {
-            lastErr = e
-            shouldBackoff = e.retryable === true && attempt < maxAttempts
-            if (shouldBackoff) {
-                const backoff = Math.min(2000, 280 * attempt * attempt)
-                logger.warn(`[QgProxy] /pool 将重试 (${attempt}/${maxAttempts}) ${e.message},等待 ${backoff}ms`)
-            } else {
-                logger.error(`青果拉取异常: ${e.stack || e}`)
-                throw e
-            }
-        } finally {
-            await releaseFetchLock()
-        }
-
-        if (shouldBackoff) {
-            const backoff = Math.min(2000, 280 * attempt * attempt)
-            await sleep(backoff)
-        }
-    }
-
-    throw lastErr || new Error('青果提取失败')
-}
-
-async function invalidateCurrent(reason, detail) {
-    if (reason === 'request_fail' || reason === 'retry_round_post_fail' || reason === 'extra_round_fail') {
-        const detailObj =
-            typeof detail === 'object' && detail !== null ? detail : { message: String(detail || '') }
-        logger.info(
-            `[QgProxy] 隧道池模式忽略入口作废 reason=${reason} detail=${JSON.stringify(detailObj)}`
-        )
-        await recordLog({
-            event: 'invalidate',
-            detail: { reason, ignored_in_tunnel_pool: true, ...detailObj }
-        })
-        return
-    }
-
-    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 || '') }
-    const tid = d.trace_id || d.mq_task_id
-    const head = tid ? `[${tid}] ` : ''
-    logger.warn(`${head}[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 {
-        project_scope_key: getProjectKey(),
-        proxy_enabled: getProjectProxyEnabledFromSettings(row),
-        proxy_enabled_default: row ? Number(row.proxy_enabled) === 1 : false,
-        project_proxy_updated_at: row?.project_updated_at ?? 0,
-        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(),
-        tunnel_server_configured: hasTunnelServer(),
-        proxy_auth_configured: hasProxyAuth(),
-        redis_current: cached && cacheStillValid(cached) ? cached : cached,
-        last_fetch_log: lastFetch
-    }
-}
-
-module.exports = {
-    getQgConfig,
-    hasExtractCredentials,
-    hasProxyAuth,
-    getProjectKey,
-    getProjectProxyEnabledFromSettings,
-    ensureSettingsRow,
-    loadSettings,
-    isOutboundProxyEnabled,
-    getOutboundAxiosFragment,
-    invalidateCurrent,
-    recordFallbackDirect,
-    recordLog,
-    getStatusSnapshot,
-    parseDeadlineMs,
-    cacheStillValid,
-    getCachedParsed,
-    fetchResourceAreas,
-    REDIS_CURRENT
-}

+ 4 - 3
lib/Lepao/Worker.js

@@ -21,7 +21,7 @@ const generateGyrFromPath = require('../../plugin/Lepao/generateGyrFromPath')
 const { syncAccountInfo } = require('./syncAccountInfo')
 const { postLepaoSchool } = require('./lepaoSchoolHttp')
 const { putOssWithQgOutbound } = require('./qgOssPut')
-const QgProxyManager = require('./QgProxyManager')
+const { isProxyForwardEnabled } = require('../ProxyForwardClient')
 const { insertLedgerRecord } = require('./CountLedger')
 
 const Logger = require('../Logger')
@@ -158,11 +158,12 @@ class Worker {
     }
 
     async putOssWithFallback(sts, ossPath, content, ctx = {}) {
+        // OSS 为阿里云签名 PUT,需原样发送二进制与 Authorization;不经 RunForge JSON 转发。
         await putOssWithQgOutbound(sts, ossPath, content, {
             logger: this.logger,
             traceId: ctx.traceId,
             taskId: ctx.taskId,
-            outboundMode: ctx.outboundMode || 'auto',
+            outboundMode: 'direct',
             timeout: this.httpTimeoutMs * 2
         })
     }
@@ -1566,7 +1567,7 @@ class Worker {
                 }
 
                 try {
-                    const proxyEnabled = await QgProxyManager.isOutboundProxyEnabled()
+                    const proxyEnabled = isProxyForwardEnabled()
                     const outboundMode = proxyEnabled ? 'proxy' : 'direct'
                     const result = await this.withTimeout(
                         handler(data, {

+ 0 - 45
lib/Lepao/ipRegionLookup.js

@@ -1,45 +0,0 @@
-/**
- * 与 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
-}

+ 0 - 108
lib/Lepao/lepaoProxyLogDisplay.js

@@ -1,108 +0,0 @@
-/**
- * 管理员列表:可读摘要 + 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' },
-    proxy_self_check: { label: '代理自检', color: 'purple' },
-    proxy_self_check_skip: { label: '自检跳过', color: 'gray' },
-    proxy_self_check_fail: { label: '自检失败', color: 'red' }
-}
-
-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.trace_id) lines.push(`任务 trace:${d.trace_id}`)
-        if (d.mq_task_id) lines.push(`MQ 任务 id:${d.mq_task_id}`)
-        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') {
-        if (d.project_scope_key) lines.push(`作用项目:${d.project_scope_key}`)
-        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 (event === 'proxy_self_check') {
-        if (d.proxy_ip) lines.push(`代理出口IP:${d.proxy_ip}`)
-        if (d.http_status !== undefined) lines.push(`HTTP:${d.http_status}`)
-        if (d.target) lines.push(`目标:${d.target}`)
-    } else if (event === 'proxy_self_check_skip') {
-        if (d.reason) lines.push(`原因:${d.reason}`)
-    } else if (event === 'proxy_self_check_fail') {
-        if (d.message) lines.push(`说明:${d.message}`)
-        if (d.code) lines.push(`错误码:${d.code}`)
-        if (d.status !== undefined) lines.push(`HTTP:${d.status}`)
-    } 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
-}
-

+ 16 - 243
lib/Lepao/lepaoSchoolHttp.js

@@ -1,51 +1,5 @@
-const axios = require('axios')
-const QgProxyManager = require('./QgProxyManager')
-const { buildAxiosOutboundConfig } = require('./outboundAxiosConfig')
+const { forwardRequest, briefUrlPath } = require('../ProxyForwardClient')
 
-function sleep(ms) {
-    return new Promise(r => setTimeout(r, ms))
-}
-
-/** 外层再包几轮:应对瞬时 NO_AVAILABLE_CHANNEL、网络抖动(青果函数内部已短时持锁 backoff,此处不宜再大) */
-async function getOutboundWithBackoff(qgOpts, rounds = 2) {
-    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
-}
-
-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
-    }
-}
-
-/** 与 Worker 日志对齐:先 traceId(如 1778232257819_dfpcft),再模块名,可选 MQ 任务 id */
 function lepaoHttpLogLabel(traceId, mqTaskId) {
     let s = ''
     if (traceId) s += `[${traceId}] `
@@ -54,76 +8,8 @@ function lepaoHttpLogLabel(traceId, mqTaskId) {
     return s
 }
 
-function isQgProxyEligibleFailure(err) {
-    if (!err) return false
-    const status = err.response?.status
-    if (status === 407) return true
-    if (status === 408) return true
-    if (status === 500) 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', 'ERR_INVALID_PROTOCOL'].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
-    }
-}
-
-/** CONNECT Tunnel 后与目标站 TLS 握手前被断开——换 IP 往往无效,应少打 /get、尽快直连 */
-function isProxyTlsHandshakeReset(err) {
-    if (!err) return false
-    const code = err.code
-    if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
-    const msg = String(err.message || '')
-    return /tls|secure\s+tls|handshake/i.test(msg)
-}
-
 /**
- * @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)
-    const label = lepaoHttpLogLabel(opts.traceId, opts.mqTaskId)
-    if (!axiosMerge || axiosMerge.proxy === false || !axiosMerge.proxy) {
-        logger.info(`${label} ${phase} POST 出站=直连 path=${path}`)
-        return
-    }
-    const conn = `${axiosMerge.proxy.host}:${axiosMerge.proxy.port}`
-    if (opts.skipQgSnapshot) {
-        logger.info(
-            `${label} ${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(
-        `${label} ${phase} POST 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${path}`
-    )
-}
-
-/**
- * 对 lepao.ctbu.edu.cn 的 POST:优先隧道代理;失败快速直连并记日志(隧道池出口由服务商后台切换)。
+ * 对 lepao 学校 API 的 POST,经 RunForge-ProxyServer 转发(失败时由代理服务自动直连回退)。
  */
 async function postLepaoSchool(url, data, options = {}) {
     const {
@@ -134,136 +20,23 @@ async function postLepaoSchool(url, data, options = {}) {
         mqTaskId = null,
         traceId = null
     } = options
-    const logLabel = () => lepaoHttpLogLabel(traceId, mqTaskId)
-
-    const doPost = async (qgProxyFragment, requestTimeout = timeout) => {
-        const outbound = buildAxiosOutboundConfig(qgProxyFragment)
-        return axios.post(url, data, {
-            headers,
-            timeout: requestTimeout,
-            ...outbound
-        })
-    }
-
-    // 强制直连:策略 A 用(任务内固定出站,禁止中途切换)
-    if (outboundMode === 'direct') {
-        await logSchoolOutbound(logger, '(强制直连)', url, { proxy: false }, { mqTaskId, traceId })
-        return doPost({ proxy: false })
-    }
-
-    if (debugProxyEnabled()) {
-        const dbg = debugProxyAxiosFragment()
-        await logSchoolOutbound(logger, 'Charles调试代理', url, dbg, {
-            skipQgSnapshot: true,
-            mqTaskId,
-            traceId
-        })
-        logger?.info?.(`${logLabel()} 使用本地调试代理 LEPAO_DEBUG_PROXY`)
-        return doPost(dbg)
-    }
-
-    const qgOn = await QgProxyManager.isOutboundProxyEnabled()
-    if (!qgOn) {
-        await logSchoolOutbound(logger, '(青果出站未启用)', url, { proxy: false }, { mqTaskId, traceId })
-        return doPost({ proxy: false })
-    }
-
-    let frag
-    try {
-        frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
-    } catch (e0) {
-        if (outboundMode === 'proxy') {
-            const err = new Error(`代理模式提取失败: ${e0.message || e0}`)
-            err.code = 'PROXY_REQUIRED_EXTRACT_FAILED'
-            err.retryable = true
-            throw err
-        }
-        logger?.error?.(`${logLabel()} 青果提取多次重试仍失败,改直连: ${e0.message || e0}`)
-        await logSchoolOutbound(logger, '(青果提取异常→直连)', url, { proxy: false }, { mqTaskId, traceId })
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'qg_extract_error',
-            mq_task_id: mqTaskId,
-            trace_id: traceId,
-            ...summarizeAxiosError(e0)
-        })
-        return doPost({ proxy: false })
-    }
-
-    if (frag.proxy === false) {
-        try {
-            await sleep(400)
-            frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
-        } catch {
-            /* 保持 frag 原状 */
-        }
-    }
-
-    if (frag.proxy === false) {
-        logger?.warn?.(`${logLabel()} 无可用青果节点,对学校 POST 将直连`)
-        if (outboundMode === 'proxy') {
-            const err = new Error('代理模式无可用节点')
-            err.code = 'PROXY_REQUIRED_NO_NODE'
-            err.retryable = true
-            throw err
-        }
-        await logSchoolOutbound(logger, '(无缓存节点→直连)', url, { proxy: false }, { mqTaskId, traceId })
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'no_proxy_available',
-            mq_task_id: mqTaskId,
-            trace_id: traceId
-        })
-        return doPost({ proxy: false })
-    }
-
-    await logSchoolOutbound(logger, '首次请求', url, frag, { mqTaskId, traceId })
-    try {
-        const proxyFirstTimeoutMs = 20000
-        return await doPost(frag, proxyFirstTimeoutMs)
-    } catch (e1) {
-        if (outboundMode === 'proxy') {
-            const err = new Error(`代理模式请求失败: ${e1.message || e1}`)
-            err.code = 'PROXY_REQUIRED_REQUEST_FAILED'
-            err.retryable = true
-            throw err
-        }
-        if (!isQgProxyEligibleFailure(e1)) throw e1
-        logger?.warn?.(
-            `${logLabel()} 经代理首次请求失败,将直接回退直连。err=${e1.message || e1} ${JSON.stringify(
-                summarizeAxiosError(e1)
-            )}`
-        )
 
-        const tls1 = isProxyTlsHandshakeReset(e1)
-        if (tls1) {
-            logger?.warn?.(
-                `${logLabel()} TLS 握手前经代理断开,隧道池模式直接直连(由服务商后台自动切换出口)`
-            )
-            await logSchoolOutbound(logger, '(TLS隧道异常→直连)', url, { proxy: false }, { mqTaskId, traceId })
-            await QgProxyManager.recordFallbackDirect({
-                reason: 'tls_prefinish_reset_direct',
-                path: briefUrlPath(url),
-                mq_task_id: mqTaskId,
-                trace_id: traceId,
-                ...summarizeAxiosError(e1)
-            })
-            return doPost({ proxy: false })
-        }
+    const logPrefix = lepaoHttpLogLabel(traceId, mqTaskId)
+    logger?.info?.(`${logPrefix} POST ${briefUrlPath(url)} mode=${outboundMode}`)
 
-        await logSchoolOutbound(logger, '(代理失败→直连)', url, { proxy: false }, { mqTaskId, traceId })
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'proxy_post_failed_then_direct',
-            path: briefUrlPath(url),
-            mq_task_id: mqTaskId,
-            trace_id: traceId,
-            ...summarizeAxiosError(e1)
-        })
-        return doPost({ proxy: false })
-    }
+    return forwardRequest({
+        method: 'post',
+        url,
+        data,
+        headers,
+        timeout,
+        outboundMode,
+        logger,
+        logPrefix,
+        scene: 'lepao_school'
+    })
 }
 
 module.exports = {
-    postLepaoSchool,
-    isQgProxyEligibleFailure,
-    debugProxyEnabled,
-    debugProxyAxiosFragment
+    postLepaoSchool
 }

+ 0 - 44
lib/Lepao/outboundAxiosConfig.js

@@ -1,44 +0,0 @@
-/**
- * 统一创建 HTTPS 出站代理 Agent,兼容 https-proxy-agent 各版本的导出方式。
- */
-function resolveHttpsProxyAgentClass() {
-    const mod = require('https-proxy-agent')
-    const AgentClass = mod.HttpsProxyAgent || mod.default || mod
-    if (typeof AgentClass !== 'function') {
-        throw new Error('https-proxy-agent 未正确安装或导出格式不兼容')
-    }
-    return AgentClass
-}
-
-function createHttpsProxyAgent(proxyUrl) {
-    const HttpsProxyAgent = resolveHttpsProxyAgentClass()
-    const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
-    return new HttpsProxyAgent(proxyUrl, { rejectUnauthorized })
-}
-
-/**
- * Axios 对「HTTPS 目标 + 内置 proxy」在部分 Node 版本下会报 ERR_INVALID_PROTOCOL,
- * 改用 HttpsProxyAgent 走 CONNECT 隧道,并显式 proxy: false。
- */
-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: createHttpsProxyAgent(proxyUrl)
-    }
-}
-
-module.exports = {
-    createHttpsProxyAgent,
-    buildAxiosOutboundConfig
-}

+ 256 - 159
lib/Lepao/qgOssPut.js

@@ -1,214 +1,311 @@
 /**
- * 乐跑轨迹/陀螺仪 OSS 上传:与 lepaoSchoolHttp 一致,优先青果 HTTP 代理,失败换节点重试后回退直连。
+
+ * OSS 上传:直连阿里云(ali-oss SDK 自行签名);乐跑学校 API 仍经 RunForge 代理。
+
  */
+
 const OSS = require('ali-oss')
-const QgProxyManager = require('./QgProxyManager')
-const {
-    buildAxiosOutboundConfig,
-    getOutboundWithBackoff,
-    isQgProxyEligibleFailure,
-    isProxyTlsHandshakeReset,
-    summarizeAxiosError,
-    debugProxyEnabled,
-    debugProxyAxiosFragment,
-    sleep,
-    PROXY_FIRST_TIMEOUT_MS
-} = require('./qgOutboundAxios')
-
-const OSS_PROXY_FIRST_TIMEOUT_MS = Math.max(PROXY_FIRST_TIMEOUT_MS, 45000)
+
+const { forwardRequest } = require('../ProxyForwardClient')
+
+
 
 function ossLogLabel(traceId, taskId) {
+
     let s = ''
+
     if (traceId) s += `[${traceId}] `
-    s += '[qgOssPut]'
+
+    s += '[ossPut]'
+
     if (taskId) s += ` [${taskId}]`
+
     return s
+
 }
 
-function buildOssClient(sts, httpsAgent, timeoutMs) {
-    const opts = {
+
+
+function buildOssClient(sts, timeoutMs) {
+
+    return new OSS({
+
         bucket: sts.bucket,
+
         region: sts.region || 'oss-cn-hangzhou',
+
         accessKeyId: sts.AccessKeyId,
+
         accessKeySecret: sts.AccessKeySecret,
+
         stsToken: sts.SecurityToken,
+
         secure: true,
+
         timeout: timeoutMs
+
+    })
+
+}
+
+
+
+/** ali-oss urllib 期望 { status, statusCode, headers, data, res } */
+
+function toUrllibResponse(resp) {
+
+    const status = resp?.status ?? resp?.statusCode
+
+    if (status == null) {
+
+        const err = new Error('代理转发未返回有效 HTTP status')
+
+        err.code = 'PROXY_FORWARD_BAD_RESPONSE'
+
+        throw err
+
     }
-    if (httpsAgent) {
-        opts.httpsAgent = httpsAgent
+
+
+
+    let data = resp.data
+
+    if (data == null || data === '') {
+
+        data = Buffer.alloc(0)
+
+    } else if (!Buffer.isBuffer(data)) {
+
+        data = Buffer.from(typeof data === 'string' ? data : JSON.stringify(data))
+
+    }
+
+
+
+    const headers = resp.headers || {}
+
+    return {
+
+        status,
+
+        statusCode: status,
+
+        headers,
+
+        data,
+
+        res: { statusCode: status, headers }
+
     }
-    return new OSS(opts)
+
 }
 
-async function logOssOutbound(logger, phase, ossPath, fragment, opts = {}) {
-    if (!logger?.info) return
-    const label = ossLogLabel(opts.traceId, opts.taskId)
-    if (!fragment || fragment.proxy === false || !fragment.proxy) {
-        logger.info(`${label} ${phase} PUT 出站=直连 path=${ossPath}`)
-        return
+
+
+function patchOssClientForForward(client, { outboundMode, timeout, logger, logPrefix, scene, ossPath }) {
+
+    const urllibMod = client.urllib
+
+    const originalRequest = urllibMod.request.bind(urllibMod)
+
+
+
+    async function runForward(reqUrl, reqOpt) {
+
+        const method = (reqOpt.method || 'GET').toUpperCase()
+
+        const headers = reqOpt.headers || {}
+
+        const body = reqOpt.content ?? reqOpt.body
+
+
+
+        if (outboundMode === 'direct') {
+
+            return originalRequest(reqUrl, reqOpt)
+
+        }
+
+
+
+        try {
+
+            const resp = await forwardRequest({
+
+                method,
+
+                url: reqUrl,
+
+                data: body,
+
+                headers,
+
+                timeout: reqOpt.timeout || timeout,
+
+                outboundMode,
+
+                logger,
+
+                logPrefix,
+
+                scene: scene || 'oss_put',
+
+                responseType: 'arraybuffer',
+
+                validateStatus: () => true
+
+            })
+
+
+
+            return toUrllibResponse(resp)
+
+        } catch (e) {
+
+            if (outboundMode === 'proxy') throw e
+
+            logger?.warn?.(`${logPrefix} OSS 经代理失败,改 SDK 直连: ${e.message || e}`)
+
+            return originalRequest(reqUrl, reqOpt)
+
+        }
+
     }
-    const conn = `${fragment.proxy.host}:${fragment.proxy.port}`
-    if (opts.skipQgSnapshot) {
-        logger.info(`${label} ${phase} PUT 出站=调试HTTP代理 连接=${conn} path=${ossPath}`)
-        return
+
+
+
+    // 兼容 urllib 的 callback / Promise 双模式,避免 ali-oss 收到 undefined status
+
+    urllibMod.request = function patchedRequest(url, args, callback) {
+
+        if (arguments.length === 2 && typeof args === 'function') {
+
+            callback = args
+
+            args = null
+
+        }
+
+        args = args || {}
+
+
+
+        if (typeof callback === 'function') {
+
+            runForward(url, args).then(
+
+                (result) => {
+
+                    if (!result || result.status == null) {
+
+                        callback(new Error('OSS HTTP 响应缺少 status'))
+
+                        return
+
+                    }
+
+                    callback(null, result.data, result.res)
+
+                },
+
+                (err) => callback(err)
+
+            )
+
+            return
+
+        }
+
+
+
+        return runForward(url, args)
+
     }
-    const snap = await QgProxyManager.getCachedParsed()
-    const serverRecord = snap?.server ?? conn
-    const egress = snap?.proxyIp ?? '(暂无 proxy_ip)'
-    const dl = snap?.deadline ?? '—'
-    logger.info(
-        `${label} ${phase} PUT 出站=HTTP代理 节点server=${serverRecord} 连接${conn} 出口IP(proxy_ip)=${egress} deadline=${dl} path=${ossPath}`
-    )
-}
 
-async function doOssPut(sts, ossPath, content, fragment, requestTimeoutMs) {
-    const outbound = buildAxiosOutboundConfig(fragment)
-    const client = buildOssClient(sts, outbound.httpsAgent, requestTimeoutMs)
-    return client.put(ossPath, content)
+
+
+    logger?.info?.(`${logPrefix} PUT ${ossPath} mode=${outboundMode}`)
+
 }
 
+
+
 /**
+
  * @param {object} sts OSS STS 凭证
+
  * @param {string} ossPath 对象 key
+
  * @param {Buffer|string} content
+
  * @param {{ logger?: object, traceId?: string, taskId?: string, outboundMode?: 'auto'|'direct'|'proxy', timeout?: number }} options
+
  */
+
 async function putOssWithQgOutbound(sts, ossPath, content, options = {}) {
+
     const {
+
         logger = null,
+
         traceId = null,
+
         taskId = null,
-        outboundMode = 'auto',
+
+        outboundMode = 'direct',
+
         timeout = 60000
+
     } = options
-    const logLabel = () => ossLogLabel(traceId, taskId)
-    const logCtx = { traceId, taskId }
+
+
+
+    const logPrefix = () => ossLogLabel(traceId, taskId)
+
+
 
     if (outboundMode === 'direct') {
-        await logOssOutbound(logger, '(强制直连)', ossPath, { proxy: false }, logCtx)
-        return doOssPut(sts, ossPath, content, { proxy: false }, timeout)
-    }
 
-    if (debugProxyEnabled()) {
-        const dbg = debugProxyAxiosFragment()
-        await logOssOutbound(logger, 'Charles调试代理', ossPath, dbg, { ...logCtx, skipQgSnapshot: true })
-        logger?.info?.(`${logLabel()} 使用本地调试代理 LEPAO_DEBUG_PROXY`)
-        return doOssPut(sts, ossPath, content, dbg, timeout)
-    }
+        logger?.info?.(`${logPrefix()} PUT 直连 ${ossPath}`)
 
-    const qgOn = await QgProxyManager.isOutboundProxyEnabled()
-    if (!qgOn) {
-        await logOssOutbound(logger, '(青果出站未启用)', ossPath, { proxy: false }, logCtx)
-        return doOssPut(sts, ossPath, content, { proxy: false }, timeout)
-    }
+        const client = buildOssClient(sts, timeout)
 
-    let frag
-    try {
-        frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
-    } catch (e0) {
-        if (outboundMode === 'proxy') {
-            const err = new Error(`代理模式提取失败: ${e0.message || e0}`)
-            err.code = 'PROXY_REQUIRED_EXTRACT_FAILED'
-            err.retryable = true
-            throw err
-        }
-        logger?.error?.(`${logLabel()} 青果提取多次重试仍失败,OSS 改直连: ${e0.message || e0}`)
-        await logOssOutbound(logger, '(青果提取异常→直连)', ossPath, { proxy: false }, logCtx)
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'qg_extract_error_oss',
-            path: ossPath,
-            mq_task_id: taskId,
-            trace_id: traceId,
-            ...summarizeAxiosError(e0)
-        })
-        return doOssPut(sts, ossPath, content, { proxy: false }, timeout)
-    }
+        return client.put(ossPath, content)
 
-    if (frag.proxy === false) {
-        try {
-            await sleep(400)
-            frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
-        } catch {
-            /* keep */
-        }
     }
 
-    if (frag.proxy === false) {
-        if (outboundMode === 'proxy') {
-            const err = new Error('代理模式无可用节点')
-            err.code = 'PROXY_REQUIRED_NO_NODE'
-            err.retryable = true
-            throw err
-        }
-        logger?.warn?.(`${logLabel()} 无可用青果节点,OSS 将直连`)
-        await logOssOutbound(logger, '(无缓存节点→直连)', ossPath, { proxy: false }, logCtx)
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'no_proxy_available_oss',
-            path: ossPath,
-            mq_task_id: taskId,
-            trace_id: traceId
-        })
-        return doOssPut(sts, ossPath, content, { proxy: false }, timeout)
-    }
 
-    await logOssOutbound(logger, '首次请求', ossPath, frag, logCtx)
-    try {
-        return await doOssPut(sts, ossPath, content, frag, OSS_PROXY_FIRST_TIMEOUT_MS)
-    } catch (e1) {
-        if (outboundMode === 'proxy') {
-            const err = new Error(`代理模式 OSS 上传失败: ${e1.message || e1}`)
-            err.code = 'PROXY_REQUIRED_OSS_PUT_FAILED'
-            err.retryable = true
-            throw err
-        }
-        if (!isQgProxyEligibleFailure(e1)) throw e1
-
-        const tls1 = isProxyTlsHandshakeReset(e1)
-        if (!tls1) {
-            logger?.warn?.(
-                `${logLabel()} 经代理 OSS 首次失败,作废缓存并换节点重试。err=${e1.message || e1} ${JSON.stringify(
-                    summarizeAxiosError(e1)
-                )}`
-            )
-            try {
-                await QgProxyManager.invalidateCurrent('oss_put_proxy_fail', {
-                    path: ossPath,
-                    mq_task_id: taskId,
-                    trace_id: traceId,
-                    ...summarizeAxiosError(e1)
-                })
-                const frag2 = await getOutboundWithBackoff({ forceRefresh: true }, 2)
-                if (frag2?.proxy !== false) {
-                    await logOssOutbound(logger, '(换节点重试)', ossPath, frag2, logCtx)
-                    return await doOssPut(sts, ossPath, content, frag2, OSS_PROXY_FIRST_TIMEOUT_MS)
-                }
-            } catch (eRetry) {
-                if (!isQgProxyEligibleFailure(eRetry)) throw eRetry
-                logger?.warn?.(
-                    `${logLabel()} 换节点重试仍失败: ${eRetry.message || eRetry} ${JSON.stringify(
-                        summarizeAxiosError(eRetry)
-                    )}`
-                )
-            }
-        } else {
-            logger?.warn?.(
-                `${logLabel()} TLS 握手前经代理断开,OSS 直接回退直连(隧道池由服务商切换出口)`
-            )
-        }
 
-        await logOssOutbound(logger, tls1 ? '(TLS隧道异常→直连)' : '(代理失败→直连)', ossPath, { proxy: false }, logCtx)
-        await QgProxyManager.recordFallbackDirect({
-            reason: tls1 ? 'tls_prefinish_reset_direct_oss' : 'proxy_oss_put_failed_then_direct',
-            path: ossPath,
-            mq_task_id: taskId,
-            trace_id: traceId,
-            ...summarizeAxiosError(e1)
-        })
-        return doOssPut(sts, ossPath, content, { proxy: false }, timeout)
-    }
+    const client = buildOssClient(sts, timeout)
+
+    patchOssClientForForward(client, {
+
+        outboundMode,
+
+        timeout,
+
+        logger,
+
+        logPrefix: logPrefix(),
+
+        scene: 'oss_put',
+
+        ossPath
+
+    })
+
+
+
+    return client.put(ossPath, content)
+
 }
 
+
+
 module.exports = {
+
     putOssWithQgOutbound
+
 }
+
+

+ 18 - 225
lib/Lepao/qgOutboundAxios.js

@@ -1,112 +1,25 @@
 /**
- * 与 lepaoSchoolHttp 一致的青果 HTTP 出站:HttpsProxyAgent + 先代理(短超时)再可回退直连。
- * 供电费、选课书单等非 Worker 场景的对外 GET/POST 使用。
+ * 通用 HTTP 出站:经 RunForge-ProxyServer 转发。
  */
-const axios = require('axios')
-const QgProxyManager = require('./QgProxyManager')
-const {
-    buildAxiosOutboundConfig
-} = require('./outboundAxiosConfig')
-
-const PROXY_FIRST_TIMEOUT_MS = 20000
-
-function sleep(ms) {
-    return new Promise(r => setTimeout(r, ms))
-}
-
-async function getOutboundWithBackoff(qgOpts, rounds = 2) {
-    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
-}
-
-
-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 || status === 408 || status === 500) 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', 'ERR_INVALID_PROTOCOL'].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
-    }
-}
-
-function isProxyTlsHandshakeReset(err) {
-    if (!err) return false
-    const code = err.code
-    if (code !== 'ECONNRESET' && code !== 'ECONNABORTED') return false
-    const msg = String(err.message || '')
-    return /tls|secure\s+tls|handshake/i.test(msg)
-}
+const { forwardRequest } = require('../ProxyForwardClient')
 
 function logLabel(traceId, mqTaskId) {
     let s = ''
     if (traceId) s += `[${traceId}] `
-    s += '[qgOutboundAxios]'
+    s += '[outboundAxios]'
     if (mqTaskId) s += ` [${mqTaskId}]`
     return s
 }
 
 /**
  * @param {{
- *   method?: 'get'|'post'
+ *   method?: 'get'|'post'|'put'|'patch'|'delete'|'head'|'options'
  *   url: string
  *   data?: any
  *   headers?: object
  *   timeout?: number
  *   outboundMode?: 'auto'|'direct'|'proxy'
- *   logger?: { info?: Function, warn?: Function, error?: Function }
+ *   logger?: object
  *   traceId?: string|null
  *   mqTaskId?: string|null
  *   scene?: string
@@ -132,142 +45,22 @@ async function axiosWithQgOutbound(opts) {
         transformResponse
     } = opts
 
-    const m = String(method).toLowerCase()
-    const lbl = () => logLabel(traceId, mqTaskId)
-
-    const baseAxiosOpts = {
+    return forwardRequest({
+        method,
+        url,
+        data,
         headers,
         timeout,
-        proxy: false,
-        ...(validateStatus ? { validateStatus } : {}),
-        ...(responseType ? { responseType } : {}),
-        ...(transformResponse ? { transformResponse } : {})
-    }
-
-    const exec = (fragment, requestTimeout) => {
-        const outbound = buildAxiosOutboundConfig(fragment)
-        const merged = { ...baseAxiosOpts, ...outbound, timeout: requestTimeout }
-        if (m === 'post') {
-            return axios.post(url, data, merged)
-        }
-        return axios.get(url, merged)
-    }
-
-    if (outboundMode === 'direct') {
-        logger?.info?.(`${lbl()} (${scene}) 强制直连 ${m.toUpperCase()} ${briefUrlPath(url)}`)
-        return exec({ proxy: false }, timeout)
-    }
-
-    if (debugProxyEnabled()) {
-        const dbg = debugProxyAxiosFragment()
-        logger?.info?.(`${lbl()} (${scene}) Charles 调试代理 LEPAO_DEBUG_PROXY ${briefUrlPath(url)}`)
-        return exec(dbg, timeout)
-    }
-
-    const qgOn = await QgProxyManager.isOutboundProxyEnabled()
-    if (!qgOn) {
-        logger?.info?.(`${lbl()} (${scene}) 青果未启用 直连 ${briefUrlPath(url)}`)
-        return exec({ proxy: false }, timeout)
-    }
-
-    let frag
-    try {
-        frag = await getOutboundWithBackoff({ forceRefresh: false }, 2)
-    } catch (e0) {
-        if (outboundMode === 'proxy') {
-            const err = new Error(`代理模式提取失败: ${e0.message || e0}`)
-            err.code = 'PROXY_REQUIRED_EXTRACT_FAILED'
-            err.retryable = true
-            throw err
-        }
-        logger?.error?.(`${lbl()} (${scene}) 青果提取失败改直连: ${e0.message || e0}`)
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'qg_extract_error',
-            path: briefUrlPath(url),
-            scene,
-            mq_task_id: mqTaskId,
-            trace_id: traceId,
-            ...summarizeAxiosError(e0)
-        })
-        return exec({ proxy: false }, timeout)
-    }
-
-    if (frag.proxy === false) {
-        try {
-            await sleep(400)
-            frag = await getOutboundWithBackoff({ forceRefresh: true }, 2)
-        } catch {
-            /* keep */
-        }
-    }
-
-    if (frag.proxy === false) {
-        if (outboundMode === 'proxy') {
-            const err = new Error('代理模式无可用节点')
-            err.code = 'PROXY_REQUIRED_NO_NODE'
-            err.retryable = true
-            throw err
-        }
-        logger?.warn?.(`${lbl()} (${scene}) 无可用节点 直连 ${briefUrlPath(url)}`)
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'no_proxy_available',
-            path: briefUrlPath(url),
-            scene,
-            mq_task_id: mqTaskId,
-            trace_id: traceId
-        })
-        return exec({ proxy: false }, timeout)
-    }
-
-    logger?.info?.(`${lbl()} (${scene}) 经代理 ${m.toUpperCase()} ${briefUrlPath(url)}`)
-    try {
-        return await exec(frag, PROXY_FIRST_TIMEOUT_MS)
-    } catch (e1) {
-        if (outboundMode === 'proxy') {
-            const err = new Error(`代理模式请求失败: ${e1.message || e1}`)
-            err.code = 'PROXY_REQUIRED_REQUEST_FAILED'
-            err.retryable = true
-            throw err
-        }
-        if (!isQgProxyEligibleFailure(e1)) throw e1
-
-        logger?.warn?.(
-            `${lbl()} (${scene}) 代理失败回退直连 err=${e1.message || e1} ${JSON.stringify(summarizeAxiosError(e1))}`
-        )
-
-        if (isProxyTlsHandshakeReset(e1)) {
-            await QgProxyManager.recordFallbackDirect({
-                reason: 'tls_prefinish_reset_direct',
-                path: briefUrlPath(url),
-                scene,
-                mq_task_id: mqTaskId,
-                trace_id: traceId,
-                ...summarizeAxiosError(e1)
-            })
-            return exec({ proxy: false }, timeout)
-        }
-
-        await QgProxyManager.recordFallbackDirect({
-            reason: 'proxy_post_failed_then_direct',
-            path: briefUrlPath(url),
-            scene,
-            mq_task_id: mqTaskId,
-            trace_id: traceId,
-            ...summarizeAxiosError(e1)
-        })
-        return exec({ proxy: false }, timeout)
-    }
+        outboundMode,
+        validateStatus,
+        responseType,
+        transformResponse,
+        logger,
+        logPrefix: logLabel(traceId, mqTaskId),
+        scene
+    })
 }
 
 module.exports = {
-    axiosWithQgOutbound,
-    buildAxiosOutboundConfig,
-    getOutboundWithBackoff,
-    isQgProxyEligibleFailure,
-    isProxyTlsHandshakeReset,
-    summarizeAxiosError,
-    debugProxyEnabled,
-    debugProxyAxiosFragment,
-    sleep,
-    PROXY_FIRST_TIMEOUT_MS
+    axiosWithQgOutbound
 }

+ 286 - 0
lib/ProxyForwardClient.js

@@ -0,0 +1,286 @@
+/**
+ * 通过 RunForge-ProxyServer 的 /Proxy/Forward 发起出站 HTTP 请求。
+ */
+const axios = require('axios')
+const config = require('../config.json')
+
+function getServerConfig() {
+    const cfg = config.proxyForwardServer
+    if (!cfg || typeof cfg !== 'object') return { url: '', enabled: false }
+    return {
+        url: String(cfg.url || '').trim().replace(/\/+$/, ''),
+        enabled: cfg.enabled !== false,
+        defaultTimeout: Number(cfg.timeout) || 120000
+    }
+}
+
+function isProxyForwardEnabled() {
+    const { url, enabled } = getServerConfig()
+    return enabled && url.length > 0
+}
+
+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
+    }
+}
+
+/** 转为可 JSON 序列化的纯对象,避免 AxiosHeaders 等类型丢失字段 */
+function normalizeForwardHeaders(headers) {
+    if (headers == null) return {}
+    let source = headers
+    if (typeof headers === 'string') {
+        try {
+            source = JSON.parse(headers)
+        } catch {
+            return {}
+        }
+    }
+    if (typeof source !== 'object' || Array.isArray(source)) return {}
+    if (typeof source.toJSON === 'function') source = source.toJSON()
+    const out = {}
+    for (const [key, value] of Object.entries(source)) {
+        if (value == null) continue
+        out[String(key)] = Array.isArray(value) ? value.join(', ') : String(value)
+    }
+    return out
+}
+
+/**
+ * URLSearchParams / Buffer 等无法被 JSON.stringify 正确序列化,需先转换再发给代理服务。
+ */
+function serializeForwardBody(data) {
+    if (data == null || data === '') return null
+    if (typeof data === 'string') return data
+    if (Buffer.isBuffer(data)) {
+        return { __encoding: 'base64', data: data.toString('base64') }
+    }
+    if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
+        return data.toString()
+    }
+    if (typeof data === 'object' && !Array.isArray(data)) return data
+    return String(data)
+}
+
+function buildDirectAxiosConfig(extra = {}) {
+    if (debugProxyEnabled()) {
+        const { HttpsProxyAgent } = require('https-proxy-agent')
+        const dbg = debugProxyAxiosFragment()
+        const { host, port } = dbg.proxy
+        const agent = new HttpsProxyAgent(`http://${host}:${port}`)
+        return { proxy: false, httpAgent: agent, httpsAgent: agent, ...extra }
+    }
+    return { proxy: false, ...extra }
+}
+
+async function execDirect({ method, url, data, headers, timeout, validateStatus, responseType, transformResponse }) {
+    const m = String(method || 'get').toLowerCase()
+    const merged = {
+        headers: headers || {},
+        timeout,
+        ...buildDirectAxiosConfig(),
+        ...(validateStatus ? { validateStatus } : {}),
+        ...(responseType ? { responseType } : {}),
+        ...(transformResponse ? { transformResponse } : {})
+    }
+    return axios.request({ ...merged, method: m, url, data })
+}
+
+function assertProxyModeOk(outboundMode, meta) {
+    if (outboundMode !== 'proxy') return
+    if (meta?.fallback_direct) {
+        const err = new Error('代理模式发生直连回退,任务中止')
+        err.code = 'PROXY_REQUIRED_FALLBACK_DIRECT'
+        err.retryable = true
+        throw err
+    }
+    if (!meta?.used_proxy) {
+        const err = new Error('代理模式未使用代理节点')
+        err.code = 'PROXY_REQUIRED_NO_PROXY_USED'
+        err.retryable = true
+        throw err
+    }
+}
+
+function toAxiosResponse(forwardData, responseType) {
+    if (!forwardData || typeof forwardData !== 'object') return null
+    const status = forwardData.status ?? forwardData.statusCode
+    if (status == null) return null
+
+    let data = forwardData.body
+    if (data && typeof data === 'object' && data.__encoding === 'base64' && data.data) {
+        data = Buffer.from(String(data.data), 'base64')
+    }
+
+    return {
+        status,
+        statusText: String(status),
+        headers: forwardData.headers || {},
+        data,
+        config: {}
+    }
+}
+
+function isValidForwardPayload(json) {
+    return json && json.code === 0 && json.data != null && (json.data.status ?? json.data.statusCode) != null
+}
+
+/**
+ * @param {{
+ *   method?: string
+ *   url: string
+ *   data?: any
+ *   headers?: object
+ *   timeout?: number
+ *   outboundMode?: 'auto'|'direct'|'proxy'
+ *   validateStatus?: Function
+ *   responseType?: string
+ *   transformResponse?: Function[]
+ *   logger?: object
+ *   logPrefix?: string
+ *   scene?: string
+ * }} opts
+ */
+async function forwardRequest(opts) {
+    const {
+        method = 'get',
+        url,
+        data,
+        headers = {},
+        timeout = 15000,
+        outboundMode = 'auto',
+        validateStatus,
+        responseType,
+        transformResponse,
+        logger,
+        logPrefix = '[ProxyForwardClient]',
+        scene = 'outbound'
+    } = opts
+
+    const m = String(method).toLowerCase()
+    const path = briefUrlPath(url)
+
+    if (outboundMode === 'direct') {
+        logger?.info?.(`${logPrefix} (${scene}) 直连 ${m.toUpperCase()} ${path}`)
+        return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
+    }
+
+    if (!isProxyForwardEnabled()) {
+        logger?.info?.(`${logPrefix} (${scene}) 未配置代理服务,直连 ${path}`)
+        if (outboundMode === 'proxy') {
+            const err = new Error('未配置 proxyForwardServer.url')
+            err.code = 'PROXY_REQUIRED_NOT_CONFIGURED'
+            err.retryable = false
+            throw err
+        }
+        return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
+    }
+
+    const { url: serverUrl, defaultTimeout } = getServerConfig()
+    logger?.info?.(`${logPrefix} (${scene}) 经代理服务 ${m.toUpperCase()} ${path}`)
+
+    const reqHeaders = normalizeForwardHeaders(headers)
+    const reqBody = serializeForwardBody(data)
+
+    let forwardResp
+    try {
+        forwardResp = await axios.post(
+            `${serverUrl}/Proxy/Forward`,
+            {
+                url,
+                method: m.toUpperCase(),
+                headers: reqHeaders,
+                body: reqBody,
+                timeout,
+                ...(responseType ? { responseType } : {})
+            },
+            {
+                timeout: Math.min(defaultTimeout, timeout + 10000),
+                validateStatus: () => true,
+                headers: { 'Content-Type': 'application/json' }
+            }
+        )
+    } catch (e) {
+        if (outboundMode === 'proxy') {
+            const err = new Error(`代理服务不可达: ${e.message || e}`)
+            err.code = 'PROXY_SERVER_UNREACHABLE'
+            err.retryable = true
+            throw err
+        }
+        logger?.warn?.(`${logPrefix} (${scene}) 代理服务请求失败,改直连: ${e.message || e}`)
+        return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
+    }
+
+    const json = forwardResp.data || {}
+    if (!isValidForwardPayload(json)) {
+        const reason = json.code !== 0 ? (json.msg || 'unknown') : '代理响应缺少 status'
+        if (outboundMode === 'proxy') {
+            const err = new Error(json.msg || reason || '代理转发失败')
+            err.code = 'PROXY_FORWARD_FAILED'
+            err.retryable = true
+            throw err
+        }
+        logger?.warn?.(`${logPrefix} (${scene}) 代理响应无效,改直连: ${reason}`)
+        return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
+    }
+
+    const meta = json.data?.meta || {}
+    assertProxyModeOk(outboundMode, meta)
+
+    logger?.info?.(
+        `${logPrefix} (${scene}) 完成 status=${json.data?.status ?? json.data?.statusCode} used_proxy=${meta.used_proxy} fallback=${meta.fallback_direct} ${meta.duration_ms}ms`
+    )
+
+    const axiosResp = toAxiosResponse(json.data, responseType)
+    if (!axiosResp) {
+        if (outboundMode === 'proxy') {
+            const err = new Error('代理响应缺少 HTTP status')
+            err.code = 'PROXY_FORWARD_BAD_RESPONSE'
+            err.retryable = true
+            throw err
+        }
+        logger?.warn?.(`${logPrefix} (${scene}) 代理响应缺少 status,改直连`)
+        return execDirect({ method: m, url, data, headers, timeout, validateStatus, responseType, transformResponse })
+    }
+
+    if (validateStatus && !validateStatus(axiosResp.status)) {
+        const err = new Error(`Request failed with status code ${axiosResp.status}`)
+        err.response = axiosResp
+        err.isAxiosError = true
+        throw err
+    }
+
+    return axiosResp
+}
+
+module.exports = {
+    forwardRequest,
+    isProxyForwardEnabled,
+    debugProxyEnabled,
+    debugProxyAxiosFragment,
+    briefUrlPath,
+    buildDirectAxiosConfig,
+    execDirect,
+    getServerConfig,
+    normalizeForwardHeaders,
+    serializeForwardBody
+}

+ 95 - 24
lib/QK/TaskScheduler.js

@@ -4,6 +4,7 @@ const config = require('../../config.json')
 const db = require('../../plugin/DataBase/db')
 const Redis = require('../../plugin/DataBase/Redis')
 const Logger = require('../Logger')
+const AccessControl = require('../AccessControl')
 
 const TASK_STATUS = {
     PAUSED: 'paused',
@@ -49,6 +50,13 @@ class TaskScheduler {
                 )
                 this.logInfo('ensureSchema', '已添加 qk_task.auto_relogin 字段')
             }
+            const jwRows = await db.query("SHOW COLUMNS FROM qk_task LIKE 'jw_account_id'")
+            if (!jwRows || jwRows.length === 0) {
+                await db.query(
+                    'ALTER TABLE qk_task ADD COLUMN jw_account_id INT NULL DEFAULT NULL AFTER student_num'
+                )
+                this.logInfo('ensureSchema', '已添加 qk_task.jw_account_id 字段')
+            }
         } catch (err) {
             this.logWarn('ensureSchema', '抢课任务表结构检查失败', {}, err)
         }
@@ -339,6 +347,49 @@ class TaskScheduler {
         }
     }
 
+    async resolveTaskAccount(ownerUuid, payload, { requireAccount = true } = {}) {
+        const jwAccountId = Number(payload.jw_account_id || payload.jwAccountId)
+        if (jwAccountId) {
+            const account = await AccessControl.getVerifiedJwAccount(ownerUuid, jwAccountId)
+            if (!account) {
+                throw new Error('统一身份认证账号不存在或未验证')
+            }
+            return {
+                student_num: account.username,
+                password: account.password,
+                jw_account_id: account.id
+            }
+        }
+        const studentNum = String(payload.student_num || payload.user || '').trim()
+        const password = payload.password || payload.pass
+        if (studentNum && password) {
+            return {
+                student_num: studentNum,
+                password,
+                jw_account_id: payload.jw_account_id || null
+            }
+        }
+        if (requireAccount) {
+            throw new Error('请选择统一身份认证账号')
+        }
+        return null
+    }
+
+    async inferJwAccountId(ownerUuid, studentNum, currentId) {
+        if (currentId) {
+            return Number(currentId)
+        }
+        const username = String(studentNum || '').trim()
+        if (!username) {
+            return null
+        }
+        const rows = await db.query(
+            'SELECT id FROM jw_account WHERE create_user = ? AND username = ? AND state = 1 LIMIT 1',
+            [ownerUuid, username]
+        )
+        return rows?.[0]?.id || null
+    }
+
     serializeTask(row, includeSecret = false) {
         const result = { ...row }
         result.enable_ggxxk = Number(result.enable_ggxxk) === 1
@@ -640,17 +691,19 @@ class TaskScheduler {
         const { courses, courseGroups, intervalMs } = validated
         const batchId = this.resolveTaskBatchId(payload)
         await this.requireEnabledBatch(batchId)
+        const account = await this.resolveTaskAccount(uuid, payload, { requireAccount: true })
         const time = Date.now()
         const sql = `INSERT INTO qk_task
-            (create_user, name, batch_id, jx0502zbid, student_num, password_enc, courses, course_groups, enable_ggxxk, auto_relogin, interval_ms, status, create_time, update_time)
-            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+            (create_user, name, batch_id, jx0502zbid, student_num, jw_account_id, password_enc, courses, course_groups, enable_ggxxk, auto_relogin, interval_ms, status, create_time, update_time)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
         const result = await db.query(sql, [
             uuid,
             payload.name,
             batchId,
             '',
-            payload.student_num || payload.user,
-            this.encryptPassword(payload.password || payload.pass),
+            account.student_num,
+            account.jw_account_id,
+            this.encryptPassword(account.password),
             JSON.stringify(courses),
             JSON.stringify(courseGroups),
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
@@ -667,7 +720,8 @@ class TaskScheduler {
         this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
             name: payload.name,
             batch_id: batchId,
-            student_num: payload.student_num || payload.user,
+            student_num: account.student_num,
+            jw_account_id: account.jw_account_id,
             courses_count: courses.length,
             course_groups_count: courseGroups.length,
             interval_ms: intervalMs,
@@ -694,11 +748,13 @@ class TaskScheduler {
         const { courses, courseGroups, intervalMs } = validated
         const batchId = this.resolveTaskBatchId(payload)
         await this.requireEnabledBatch(batchId)
-        const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
+        const account = await this.resolveTaskAccount(uuid, payload, { requireAccount: true })
         const params = [
             payload.name,
             batchId,
-            payload.student_num || payload.user,
+            account.student_num,
+            account.jw_account_id,
+            this.encryptPassword(account.password),
             JSON.stringify(courses),
             JSON.stringify(courseGroups),
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
@@ -707,11 +763,8 @@ class TaskScheduler {
             TASK_STATUS.PAUSED,
             Date.now()
         ]
-        if (passwordSql) {
-            params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
-        }
         params.push(taskId, uuid)
-        const sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, auto_relogin = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ? AND create_user = ?`
+        const sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?, jw_account_id = ?, password_enc = ?, courses = ?, course_groups = ?, enable_ggxxk = ?, auto_relogin = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ? AND create_user = ?`
         const result = await db.query(sql, params)
         if (!result || result.affectedRows <= 0) {
             throw new Error('更新抢课任务失败')
@@ -719,11 +772,12 @@ class TaskScheduler {
         await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
         this.logInfo('updateTask', '抢课任务已更新(未开始)', { taskId, uuid }, {
             name: payload.name,
-            student_num: payload.student_num || payload.user,
+            student_num: account.student_num,
+            jw_account_id: account.jw_account_id,
             courses_count: courses.length,
             course_groups_count: courseGroups.length,
             interval_ms: intervalMs,
-            password_changed: !!(payload.password || payload.pass)
+            account_changed: true
         })
     }
 
@@ -742,6 +796,12 @@ class TaskScheduler {
             where.push('t.name LIKE ?')
             params.push(`%${filters.name}%`)
         }
+        const courseName = String(filters.course_name || filters.course || '').trim()
+        if (courseName) {
+            const keyword = `%${courseName}%`
+            where.push('(t.courses LIKE ? OR t.course_groups LIKE ?)')
+            params.push(keyword, keyword)
+        }
         if (filters.batch_id) {
             where.push('t.batch_id = ?')
             params.push(Number(filters.batch_id))
@@ -769,9 +829,11 @@ class TaskScheduler {
         if (!rows || rows.length === 0) {
             return null
         }
+        const task = this.serializeTask(rows[0], false)
+        task.jw_account_id = await this.inferJwAccountId(uuid, task.student_num, task.jw_account_id)
         const logs = await db.query('SELECT client_id, event, message, payload_json, create_time FROM qk_task_log WHERE task_id = ? ORDER BY create_time DESC LIMIT 100', [taskId])
         return {
-            task: this.serializeTask(rows[0], true),
+            task,
             logs: (logs || []).map(log => {
                 if (typeof log.payload_json === 'string' && log.payload_json) {
                     try {
@@ -863,7 +925,9 @@ class TaskScheduler {
         if (!rows || rows.length === 0) {
             return null
         }
-        return this.serializeTask(rows[0], true)
+        const task = this.serializeTask(rows[0], false)
+        task.jw_account_id = await this.inferJwAccountId(rows[0].create_user, task.student_num, task.jw_account_id)
+        return task
     }
 
     async adminUpdateTask(taskId, payload) {
@@ -886,24 +950,23 @@ class TaskScheduler {
         const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
         const releasedClientId = wasAssigned ? task.assigned_client_id : null
         const batchId = this.resolveTaskBatchId(payload)
-        const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
+        const account = await this.resolveTaskAccount(task.create_user, payload, { requireAccount: true })
         const params = [
             payload.name,
             batchId,
-            payload.student_num || payload.user,
+            account.student_num,
+            account.jw_account_id,
+            this.encryptPassword(account.password),
             JSON.stringify(courses),
             JSON.stringify(courseGroups),
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             this.parseAutoRelogin(payload) ? 1 : 0,
             intervalMs,
             TASK_STATUS.PAUSED,
-            now
+            now,
+            taskId
         ]
-        if (passwordSql) {
-            params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
-        }
-        params.push(taskId)
-        const sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, auto_relogin = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ?`
+        const sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?, jw_account_id = ?, password_enc = ?, courses = ?, course_groups = ?, enable_ggxxk = ?, auto_relogin = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ?`
         const result = await db.query(sql, params)
         if (!result || result.affectedRows <= 0) {
             throw new Error('更新抢课任务失败')
@@ -914,7 +977,8 @@ class TaskScheduler {
         await this.logTask(taskId, task.assigned_client_id, 'admin_updated', wasAssigned ? '管理员更新任务并收回(未开始)' : '管理员更新抢课任务')
         this.logInfo('adminUpdateTask', '管理员已更新抢课任务', { taskId }, {
             name: payload.name,
-            student_num: payload.student_num || payload.user,
+            student_num: account.student_num,
+            jw_account_id: account.jw_account_id,
             released_from_client: wasAssigned ? task.assigned_client_id : null
         })
     }
@@ -1565,6 +1629,13 @@ class TaskScheduler {
             params.push(`%${filters.username}%`)
             countParams.push(`%${filters.username}%`)
         }
+        const courseName = String(filters.course_name || filters.course || '').trim()
+        if (courseName) {
+            const keyword = `%${courseName}%`
+            where.push('(t.courses LIKE ? OR t.course_groups LIKE ?)')
+            params.push(keyword, keyword)
+            countParams.push(keyword, keyword)
+        }
         if (filters.batch_id) {
             where.push('t.batch_id = ?')
             params.push(Number(filters.batch_id))

+ 1 - 3
plugin/Lepao/runforgeSetZoneProbe.js

@@ -1,6 +1,4 @@
-/**
- * 直连 RunForge 切换跑区(与 Worker lepao.setZone 一致),用于 token 探活,不经过 runpy。
- */
+
 const { URLSearchParams } = require('url')
 const { postLepaoSchool } = require('../../lib/Lepao/lepaoSchoolHttp')
 const db = require('../DataBase/db')