Browse Source

✨ feat: 完善webvpn登录

Pchen0 1 day ago
parent
commit
5a5f94b3b4

+ 11 - 5
apis/Corn/StartLepao.js

@@ -35,11 +35,17 @@ class StartLepao extends API {
 
             const day = new Date().getDay()
             let sql = `
-                        SELECT name, student_num
-                        FROM lepao_account
-                        WHERE auto_run = 1 AND state = 1
-                            AND JSON_CONTAINS(auto_day, CAST(? AS JSON))
-                            AND (auto_time = ? OR (auto_time = -1 AND today_auto_time = ?))
+                        SELECT l.name, l.student_num
+                        FROM lepao_account l
+                        INNER JOIN jw_account j
+                            ON j.create_user = l.create_user
+                            AND j.username = l.student_num
+                            AND j.state = 1
+                            AND j.password IS NOT NULL
+                            AND j.password <> ''
+                        WHERE l.auto_run = 1 AND l.state = 1
+                            AND JSON_CONTAINS(l.auto_day, CAST(? AS JSON))
+                            AND (l.auto_time = ? OR (l.auto_time = -1 AND l.today_auto_time = ?))
                         `
             let r = await db.query(sql, [day, time, time])
             if (!r)

+ 1 - 1
apis/JW/ActiveAccount.js

@@ -4,7 +4,7 @@ const AccessControl = require("../../lib/AccessControl");
 const ic = require("../../lib/IC/IC").IC;
 const { BaseStdResponse } = require("../../BaseStdResponse.js");
 
-// 激活教务账号
+// 激活统一认证账号
 class ActiveAccount extends API {
     constructor() {
         super();

+ 43 - 22
apis/Lepao/Account/AddAccount.js

@@ -5,6 +5,7 @@ const { BaseStdResponse } = require("../../../BaseStdResponse.js");
 const AccessControl = require("../../../lib/AccessControl.js");
 const { insertBindAudit, BindAuditAction, BindAuditSource } = require("../../../lib/Lepao/BindAudit.js");
 const { fetchWebVpnCookieFromPy, invalidateWebVpnCookie } = require("../../../lib/Lepao/webvpnCookie");
+const { syncAccountInfo } = require("../../../lib/Lepao/syncAccountInfo");
 
 class AddAccount extends API {
     constructor() {
@@ -75,7 +76,7 @@ class AddAccount extends API {
         if (!id && [jw_password].some(value => value === '' || value === null || value === undefined))
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER,
-                msg: '新绑定乐跑账号时必须提供教务系统密码(jw_password)'
+                msg: '新绑定乐跑账号时必须提供统一认证密码'
             })
 
         if (isNaN(target_count) || target_count < 0 || target_count > 99) {
@@ -121,18 +122,21 @@ class AddAccount extends API {
             } catch {
                 return res.json({
                     ...BaseStdResponse.ERR,
-                    msg: '教务密码格式错误'
+                    msg: '统一认证密码格式错误'
                 })
             }
         }
 
         if (plainJwPassword) {
             try {
-                await fetchWebVpnCookieFromPy(jwLoginName, plainJwPassword)
+                await fetchWebVpnCookieFromPy(jwLoginName, plainJwPassword, {
+                    createUserUuid: uuid,
+                    lepaoStudentNum: jwLoginName
+                })
             } catch (e) {
                 return res.json({
                     ...BaseStdResponse.ERR,
-                    msg: `教务账号或密码无法通过 WebVPN 校验:${e.message || '请核对后重试'}`
+                    msg: `统一认证账号无法通过校验:${e.message || '请核对后重试'}`
                 })
             }
             try {
@@ -171,24 +175,6 @@ class AddAccount extends API {
 
         const time = new Date().getTime()
 
-        if (plainJwPassword) {
-            const jwExisting = await db.query(
-                'SELECT id FROM jw_account WHERE create_user = ? AND username = ?',
-                [uuid, jwLoginName]
-            )
-            if (jwExisting && jwExisting.length > 0) {
-                await db.query(
-                    'UPDATE jw_account SET password = ?, update_time = ?, state = 0 WHERE id = ?',
-                    [plainJwPassword, time, jwExisting[0].id]
-                )
-            } else {
-                await db.query(
-                    'INSERT INTO jw_account (username, password, create_user, create_time) VALUES (?, ?, ?, ?)',
-                    [jwLoginName, plainJwPassword, uuid, time]
-                )
-            }
-        }
-
         const previousOwner = countRows.length !== 0 ? countRows[0].create_user : null
         const shouldAutoUnbindAndRebind = !id &&
             countRows.length !== 0 &&
@@ -221,6 +207,41 @@ class AddAccount extends API {
 
         try {
             if (r && r.affectedRows > 0) {
+                if (plainJwPassword) {
+                    const jwExisting = await db.query(
+                        'SELECT id FROM jw_account WHERE create_user = ? AND username = ?',
+                        [uuid, jwLoginName]
+                    )
+                    if (jwExisting && jwExisting.length > 0) {
+                        await db.query(
+                            'UPDATE jw_account SET password = ?, update_time = ?, state = 1 WHERE id = ?',
+                            [plainJwPassword, time, jwExisting[0].id]
+                        )
+                    } else {
+                        await db.query(
+                            'INSERT INTO jw_account (username, password, create_user, create_time, state) VALUES (?, ?, ?, ?, ?)',
+                            [jwLoginName, plainJwPassword, uuid, time, 1]
+                        )
+                    }
+                    try {
+                        const tk = await db.query(
+                            'SELECT uid, token FROM lepao_account WHERE student_num = ? AND create_user = ? LIMIT 1',
+                            [student_num, uuid]
+                        )
+                        if (tk?.[0]?.uid && tk?.[0]?.token) {
+                            await syncAccountInfo({
+                                studentNum: student_num,
+                                createUser: uuid,
+                                logger: this.logger
+                            })
+                        }
+                    } catch (syncErr) {
+                        this.logger.warn(
+                            `乐跑账号绑定后同步失败 student=${student_num}: ${syncErr.stack || syncErr}`
+                        )
+                    }
+                }
+
                 const selectSql = `
                                     SELECT 
                                         a.id, a.create_user, a.total_num, e.bind_code, e.bot_account

+ 14 - 1
apis/Lepao/Account/Admin/GetAccountList.js

@@ -82,7 +82,10 @@ class GetAccountList extends API {
                     f.bot_umo,
                     f.state AS face_state,
                     f.create_time AS face_time,
-                    f.url AS face_url
+                    f.url AS face_url,
+                    j.state AS jw_state,
+                    j.realname AS jw_realname,
+                    j.password AS jw_password
                 FROM 
                     lepao_account l
                 LEFT JOIN 
@@ -93,6 +96,11 @@ class GetAccountList extends API {
                     lepao_extra f 
                 ON
                     l.student_num = f.student_num
+                LEFT JOIN
+                    jw_account j
+                ON
+                    j.create_user = l.create_user
+                    AND j.username = l.student_num
                 WHERE 
                     1 = 1
             `
@@ -109,6 +117,11 @@ class GetAccountList extends API {
                 lepao_extra f 
             ON
                 l.student_num = f.student_num
+            LEFT JOIN
+                jw_account j
+            ON
+                j.create_user = l.create_user
+                AND j.username = l.student_num
             WHERE 1 = 1
         `
 

+ 19 - 7
apis/Lepao/SingleRun.js

@@ -8,6 +8,7 @@ const {
     assertRunforgeTaskIngress,
     publishRunforgeTask
 } = require('../../plugin/mq/runforgeTaskMq')
+const { assertLepaoJwBindingOrThrow } = require('../../lib/Lepao/webvpnCookie')
 
 // 单次乐跑
 class SingleRun extends API {
@@ -54,7 +55,8 @@ class SingleRun extends API {
                     msg: '该账号已进入乐跑任务队列,请等待乐跑完成后再进行乐跑操作'
                 })
 
-            let selectSql = 'SELECT create_user FROM lepao_account WHERE student_num = ?'
+            let selectSql =
+                'SELECT create_user, token, uid, school_id, state FROM lepao_account WHERE student_num = ?'
             let selectRows = await db.query(selectSql, [student_num])
             if (!selectRows || selectRows.length === 0)
                 return res.json({
@@ -71,19 +73,29 @@ class SingleRun extends API {
                     })
             }
 
-            let sql = 'SELECT token, uid, school_id, state FROM lepao_account WHERE student_num = ?'
-            let rows = await db.query(sql, [student_num])
-            if (!rows || rows.length === 0)
+            try {
+                await assertLepaoJwBindingOrThrow(selectRows[0].create_user, student_num)
+            } catch (jwErr) {
                 return res.json({
                     ...BaseStdResponse.ERR,
-                    msg: '发起乐跑失败!未找到对应的账号信息'
+                    msg: jwErr.message || '统一认证绑定校验失败'
                 })
+            }
 
-            if (rows[0].state !== 1)
+            const accState = Number(selectRows[0].state)
+            if (accState !== 1) {
+                let stateMsg = '账号状态为未登录,请使用登录器更新账号信息后乐跑'
+                if (accState === 3) {
+                    stateMsg =
+                        '统一认证登录失败,请在乐跑绑定中核对统一认证密码后重新保存,并完成教务账号激活'
+                } else if (accState === 2) {
+                    stateMsg = '账号状态异常,请使用登录器更新账号信息或联系客服'
+                }
                 return res.json({
                     ...BaseStdResponse.ERR,
-                    msg: '账号状态为未登录,请使用登录器更新账号信息后乐跑'
+                    msg: stateMsg
                 })
+            }
 
             res.json({
                 ...BaseStdResponse.OK

+ 63 - 11
lib/Lepao/Worker.js

@@ -25,7 +25,10 @@ const { insertLedgerRecord } = require('./CountLedger')
 const {
     getWebVpnCookieHeader,
     invalidateWebVpnCookie,
-    isProbablyVpnLoginHtml
+    isProbablyVpnLoginHtml,
+    isWebVpnUnifiedAuthCredentialFailure,
+    markLepaoUnifiedAuthFailed,
+    assertLepaoJwBindingOrThrow
 } = require('./webvpnCookie')
 
 const Logger = require('../Logger')
@@ -128,9 +131,23 @@ class Worker {
     async markLoginExpired(account) {
         if (!account) return
         try {
-            const sql = 'UPDATE lepao_account SET state = 0 WHERE student_num = ?'
-            await db.query(sql, [account])
-            this.logger.warn(`${account} 登录状态已失效,已自动更新为未登录`)
+            const ownerRows = await db.query(
+                'SELECT create_user FROM lepao_account WHERE student_num = ? LIMIT 1',
+                [account]
+            )
+            const owner = ownerRows?.[0]?.create_user
+            const now = Date.now()
+            await db.query('UPDATE lepao_account SET state = 0, update_time = ? WHERE student_num = ?', [
+                now,
+                account
+            ])
+            if (owner) {
+                await db.query(
+                    'UPDATE jw_account SET state = 0, update_time = ? WHERE create_user = ? AND username = ?',
+                    [now, owner, account]
+                )
+            }
+            this.logger.warn(`${account} 乐跑登录失效,lepao_account / jw_account 状态已回写`)
         } catch (error) {
             this.logger.error(`更新账号登录状态失败:${error.stack || error}`)
         }
@@ -351,11 +368,23 @@ class Worker {
                 ctx?.create_user &&
                 ctx?.lepaoStudentNum
             ) {
-                this.log(traceId, 'VPN', '响应疑似 WebVPN 登录页,刷新 Cookie 后重试', { name })
+                this.log(traceId, 'VPN', '响应疑似统一身份认证登录页,刷新 Cookie 后重试', { name })
                 await invalidateWebVpnCookie(ctx.create_user, ctx.lepaoStudentNum)
-                ctx.webvpnCookie = await getWebVpnCookieHeader(ctx.create_user, ctx.lepaoStudentNum, {
-                    skipCache: true
-                })
+                try {
+                    ctx.webvpnCookie = await getWebVpnCookieHeader(ctx.create_user, ctx.lepaoStudentNum, {
+                        skipCache: true,
+                        logger: this.logger
+                    })
+                } catch (vpnRefreshErr) {
+                    if (isWebVpnUnifiedAuthCredentialFailure(vpnRefreshErr)) {
+                        await markLepaoUnifiedAuthFailed(
+                            ctx.lepaoStudentNum,
+                            ctx.create_user,
+                            ctx.lepaoStudentNum
+                        )
+                    }
+                    throw vpnRefreshErr
+                }
                 res = await this.withTimeout(postOnce(), name)
                 try {
                     result = JSON.parse(res.data)
@@ -507,12 +536,25 @@ class Worker {
                 userData = await this.handlers['lepao.getUserData'](req, ctx)
 
                 try {
-                    ctx.webvpnCookie = await getWebVpnCookieHeader(userData.create_user, userData.student_num)
+                    ctx.webvpnCookie = await getWebVpnCookieHeader(userData.create_user, userData.student_num, {
+                        logger: this.logger
+                    })
                     ctx.create_user = userData.create_user
                     ctx.lepaoStudentNum = userData.student_num
                 } catch (vpnErr) {
-                    this.logErr(traceId, 'WebVPN 登录失败', vpnErr)
-                    throw new Error(vpnErr.message || 'WebVPN 登录失败,请检查教务账号密码是否已在乐跑绑定中填写正确')
+                    this.logErr(traceId, '统一认证登录失败', vpnErr)
+                    if (isWebVpnUnifiedAuthCredentialFailure(vpnErr) && userData.student_num) {
+                        try {
+                            await markLepaoUnifiedAuthFailed(
+                                userData.student_num,
+                                userData.create_user,
+                                userData.student_num
+                            )
+                        } catch (markErr) {
+                            this.logger.error(`标记统一认证失败 state=3 失败: ${markErr.message || markErr}`)
+                        }
+                    }
+                    throw new Error(vpnErr.message || '统一认证登录失败,请检查统一认证账号密码是否已在乐跑绑定中填写正确')
                 }
 
                 // 立刻合并账号凭证,保证后续任意 throw 时 finally 里 syncRunCount 不会用空 token 调 getRecord
@@ -981,8 +1023,18 @@ class Worker {
                 throw new Error('当前账号状态异常,请联系客服')
             }
 
+            try {
+                await assertLepaoJwBindingOrThrow(userData.create_user, userData.student_num)
+            } catch (jwBindErr) {
+                this.logger.warn(`${account}统一认证绑定校验失败: ${jwBindErr.message || jwBindErr}`)
+                throw jwBindErr
+            }
+
             if (userData.state !== 1) {
                 this.logger.warn(`${account}登录状态异常 state=${userData.state}`)
+                if (Number(userData.state) === 3) {
+                    throw new Error('统一认证登录失败,请在乐跑绑定中核对统一认证密码后重新保存,并完成教务账号激活')
+                }
                 throw new Error('乐跑账号登录已过期,请尝试使用登录器重新登录')
             }
 

+ 169 - 0
lib/Lepao/lepaoBeforeRunStateSync.js

@@ -0,0 +1,169 @@
+/**
+ * 使用 WebVPN Cookie 调用 beforeRunV260,按结果写入 lepao_account / jw_account 状态(与同步逻辑一致)。
+ * 独立于 webvpnCookie,避免循环依赖;由上层注入 invalidate / refresh WebVPN。
+ */
+const axios = require('axios')
+const db = require('../../plugin/DataBase/db')
+const { URLSearchParams } = require('url')
+const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
+
+const DEFAULT_USER_AGENT =
+    'Mozilla/5.0 (Linux; Android 16; 2211133C Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'
+
+function isProbablyVpnHtml(body) {
+    if (typeof body !== 'string') return false
+    return (
+        body.includes('lyuapServer') ||
+        body.includes('ivpn') ||
+        body.includes('统一身份认证') ||
+        body.includes('rump_frontend/login')
+    )
+}
+
+async function resetJwVerificationState(ownerUuid, jwUsername, updateTime = Date.now()) {
+    await db.query(
+        'UPDATE jw_account SET state = 0, update_time = ? WHERE create_user = ? AND username = ?',
+        [updateTime, ownerUuid, jwUsername]
+    )
+}
+
+/**
+ * @param {object} opts
+ * @param {string} opts.studentNum
+ * @param {string} opts.ownerUuid
+ * @param {string} opts.webvpnCookie
+ * @param {{ uid: string, token: string, school_id: *, userAgent?: string }} opts.account 乐跑账号行
+ * @param {string} opts.conditionSql 如 student_num = ? AND create_user = ?
+ * @param {any[]} opts.queryParams UPDATE 占位参数顺序
+ * @param {() => Promise<void>} opts.invalidateWebVpn
+ * @param {() => Promise<string>} opts.refreshWebVpnCookie
+ * @param {object} [opts.logger]
+ * @returns {Promise<{ ok: boolean, skipped?: boolean, msg?: string, loginExpired?: boolean, data?: object }>}
+ */
+async function syncLepaoStateViaBeforeRun(opts) {
+    const {
+        studentNum,
+        ownerUuid,
+        webvpnCookie: initialCookie,
+        account,
+        conditionSql,
+        queryParams,
+        invalidateWebVpn,
+        refreshWebVpnCookie,
+        logger
+    } = opts
+
+    if (!account?.uid || !account?.token) {
+        logger?.warn?.(`[beforeRun同步] 无 uid/token,跳过 student=${studentNum}`)
+        return { ok: true, skipped: true }
+    }
+
+    let webvpnCookie = initialCookie
+    const raw = {
+        uid: account.uid,
+        token: account.token,
+        school_id: account.school_id,
+        term_id: 0,
+        course_id: 0,
+        class_id: 0,
+        student_num: studentNum,
+        card_id: studentNum,
+        timestamp: Number((Date.now() / 1000).toFixed(3)),
+        version: 1,
+        nonce: String(Math.floor(Math.random() * 900000 + 100000)),
+        ostype: 5
+    }
+    raw.sign = dataSign(raw)
+
+    const form = new URLSearchParams()
+    form.append('ostype', '5')
+    form.append('data', dataEncrypt(JSON.stringify(raw)))
+
+    const buildHeaders = () => ({
+        'Content-Type': 'application/x-www-form-urlencoded',
+        Accept: '*/*',
+        'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
+        'Accept-Encoding': 'gzip, deflate, br',
+        Referer: 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
+        'User-Agent': account.userAgent || DEFAULT_USER_AGENT,
+        Cookie: webvpnCookie
+    })
+
+    let result
+    try {
+        const postOnce = () =>
+            axios.post('https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260', form, {
+                headers: buildHeaders(),
+                proxy: false,
+                responseType: 'text',
+                transformResponse: [(b) => b]
+            })
+
+        let apiRes = await postOnce()
+        try {
+            result = JSON.parse(apiRes.data)
+        } catch {
+            result = apiRes.data
+        }
+
+        if (typeof result === 'string' && isProbablyVpnHtml(result)) {
+            await invalidateWebVpn()
+            webvpnCookie = await refreshWebVpnCookie()
+            apiRes = await postOnce()
+            try {
+                result = JSON.parse(apiRes.data)
+            } catch {
+                result = apiRes.data
+            }
+        }
+
+        if (result?.data && result?.is_encrypt === 1) {
+            result.data = JSON.parse(dataDecrypt(result.data))
+        }
+    } catch (error) {
+        logger?.error?.(`[beforeRun同步] 远端失败 ${studentNum}: ${error.stack || error}`)
+        return { ok: false, msg: '同步失败,请稍后再试' }
+    }
+
+    const info = result?.info || result?.msg || '系统繁忙,请稍后再试'
+    const updateTime = Date.now()
+
+    if (String(info).includes('重新登录') || Number(result?.status) === 101) {
+        await db.query(
+            `UPDATE lepao_account SET state = 0, update_time = ? WHERE ${conditionSql}`,
+            [updateTime, ...queryParams]
+        )
+        await resetJwVerificationState(ownerUuid, studentNum, updateTime)
+        return { ok: false, msg: info, loginExpired: true }
+    }
+
+    if (!result || Number(result.status) !== 1 || !result.data) {
+        return { ok: false, msg: info }
+    }
+
+    const term_num = Number(result.data.term_num ?? 0)
+    const total_num = Number(result.data.total_num ?? 30)
+    const updateRows = await db.query(
+        `UPDATE lepao_account SET term_num = ?, total_num = ?, state = 1, update_time = ? WHERE ${conditionSql}`,
+        [term_num, total_num, updateTime, ...queryParams]
+    )
+    if (!updateRows || updateRows.affectedRows !== 1) {
+        return { ok: false, msg: '数据库更新失败' }
+    }
+
+    return {
+        ok: true,
+        data: {
+            student_num: studentNum,
+            term_num,
+            total_num,
+            state: 1
+        }
+    }
+}
+
+module.exports = {
+    syncLepaoStateViaBeforeRun,
+    resetJwVerificationState,
+    DEFAULT_USER_AGENT
+}

+ 46 - 107
lib/Lepao/syncAccountInfo.js

@@ -1,23 +1,18 @@
-const axios = require('axios')
 const db = require('../../plugin/DataBase/db')
-const { URLSearchParams } = require('url')
-const { dataEncrypt, dataDecrypt, dataSign } = require('../../plugin/Lepao/Crypto')
 const {
     getWebVpnCookieHeader,
     invalidateWebVpnCookie,
-    isProbablyVpnLoginHtml
+    isWebVpnUnifiedAuthCredentialFailure,
+    markLepaoUnifiedAuthFailed
 } = require('./webvpnCookie')
-
-const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Linux; Android 16; 2211133C Build/BP2A.250605.031.A3; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.180 Mobile Safari/537.36 XWEB/1380347 MMWEBSDK/20250202 MMWEBID/1020 wxwork/5.0.6.66174 MicroMessenger/8.0.28.48(0x28001c30) MiniProgramEnv/android Luggage/3.0.2.95ef3f83 NetType/WIFI Language/zh_CN ABI/arm64'
+const { syncLepaoStateViaBeforeRun } = require('./lepaoBeforeRunStateSync')
 
 async function syncAccountInfo({ studentNum, createUser, logger }) {
     if (!studentNum) {
         return { ok: false, msg: '缺少学号参数' }
     }
 
-    const conditionSql = createUser
-        ? 'student_num = ? AND create_user = ?'
-        : 'student_num = ?'
+    const conditionSql = createUser ? 'student_num = ? AND create_user = ?' : 'student_num = ?'
     const queryParams = createUser ? [studentNum, createUser] : [studentNum]
     const rows = await db.query(
         `SELECT uid, token, school_id, userAgent, create_user FROM lepao_account WHERE ${conditionSql}`,
@@ -32,114 +27,58 @@ async function syncAccountInfo({ studentNum, createUser, logger }) {
     if (!ownerUuid) {
         return { ok: false, msg: '账号未绑定用户,无法同步' }
     }
+
     let webvpnCookie
     try {
-        webvpnCookie = await getWebVpnCookieHeader(ownerUuid, studentNum)
+        webvpnCookie = await getWebVpnCookieHeader(ownerUuid, studentNum, {
+            skipPostWebVpnLepaoSync: true,
+            logger
+        })
     } catch (e) {
-        logger?.error?.(`WebVPN 登录失败 ${studentNum}: ${e.stack || e}`)
-        return { ok: false, msg: e.message || 'WebVPN 登录失败,请检查教务账号密码' }
-    }
-    const raw = {
-        uid: account.uid,
-        token: account.token,
-        school_id: account.school_id,
-        term_id: 0,
-        course_id: 0,
-        class_id: 0,
-        student_num: studentNum,
-        card_id: studentNum,
-        timestamp: Number((Date.now() / 1000).toFixed(3)),
-        version: 1,
-        nonce: String(Math.floor(Math.random() * 900000 + 100000)),
-        ostype: 5
-    }
-    raw.sign = dataSign(raw)
-
-    const form = new URLSearchParams()
-    form.append('ostype', '5')
-    form.append('data', dataEncrypt(JSON.stringify(raw)))
-
-    const buildHeaders = () => ({
-        'Content-Type': 'application/x-www-form-urlencoded',
-        'Accept': '*/*',
-        'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
-        'Accept-Encoding': 'gzip, deflate, br',
-        'Referer': 'https://servicewechat.com/wxf94c4ddb63d87ede/32/page-frame.html',
-        'User-Agent': account.userAgent || DEFAULT_USER_AGENT,
-        Cookie: webvpnCookie
-    })
-
-    let result
-    try {
-        const postOnce = () =>
-            axios.post('https://lepao.ctbu.edu.cn/v3/api.php/Run2/beforeRunV260', form, {
-                headers: buildHeaders(),
-                proxy: false,
-                responseType: 'text',
-                transformResponse: [(b) => b]
-            })
-
-        let apiRes = await postOnce()
-        try {
-            result = JSON.parse(apiRes.data)
-        } catch {
-            result = apiRes.data
-        }
-
-        if (typeof result === 'string' && isProbablyVpnLoginHtml(result)) {
-            await invalidateWebVpnCookie(ownerUuid, studentNum)
-            webvpnCookie = await getWebVpnCookieHeader(ownerUuid, studentNum, { skipCache: true })
-            apiRes = await postOnce()
+        logger?.error?.(`统一身份认证登录失败 ${studentNum}: ${e.stack || e}`)
+        if (isWebVpnUnifiedAuthCredentialFailure(e)) {
             try {
-                result = JSON.parse(apiRes.data)
-            } catch {
-                result = apiRes.data
+                await markLepaoUnifiedAuthFailed(studentNum, ownerUuid, studentNum)
+            } catch (markErr) {
+                logger?.error?.(`标记统一认证失败 state=3 失败 ${studentNum}: ${markErr.stack || markErr}`)
             }
         }
+        return { ok: false, msg: e.message || 'WebVPN 登录失败,请检查统一认证账号密码' }
+    }
 
-        if (result?.data && result?.is_encrypt === 1) {
-            result.data = JSON.parse(dataDecrypt(result.data))
-        }
+    try {
+        return await syncLepaoStateViaBeforeRun({
+            studentNum,
+            ownerUuid,
+            webvpnCookie,
+            account,
+            conditionSql,
+            queryParams,
+            invalidateWebVpn: () => invalidateWebVpnCookie(ownerUuid, studentNum),
+            refreshWebVpnCookie: async () => {
+                try {
+                    return await getWebVpnCookieHeader(ownerUuid, studentNum, {
+                        skipCache: true,
+                        skipPostWebVpnLepaoSync: true,
+                        logger
+                    })
+                } catch (e) {
+                    if (isWebVpnUnifiedAuthCredentialFailure(e)) {
+                        try {
+                            await markLepaoUnifiedAuthFailed(studentNum, ownerUuid, studentNum)
+                        } catch (_) {
+                            /* ignore */
+                        }
+                    }
+                    throw e
+                }
+            },
+            logger
+        })
     } catch (error) {
-        logger?.error?.(`同步乐跑账号远端请求失败 ${studentNum}: ${error.stack || error}`)
+        logger?.error?.(`同步乐跑账号失败 ${studentNum}: ${error.stack || error}`)
         return { ok: false, msg: '同步失败,请稍后再试' }
     }
-
-    const info = result?.info || result?.msg || '系统繁忙,请稍后再试'
-    const updateTime = Date.now()
-
-    // 登录失效时,仅更新状态并返回失败信息
-    if (String(info).includes('重新登录') || Number(result?.status) === 101) {
-        await db.query(
-            `UPDATE lepao_account SET state = 0, update_time = ? WHERE ${conditionSql}`,
-            [updateTime, ...queryParams]
-        )
-        return { ok: false, msg: info, loginExpired: true }
-    }
-
-    if (!result || Number(result.status) !== 1 || !result.data) {
-        return { ok: false, msg: info }
-    }
-
-    const term_num = Number(result.data.term_num ?? 0)
-    const total_num = Number(result.data.total_num ?? 30)
-    const updateRows = await db.query(
-        `UPDATE lepao_account SET term_num = ?, total_num = ?, state = 1, update_time = ? WHERE ${conditionSql}`,
-        [term_num, total_num, updateTime, ...queryParams]
-    )
-    if (!updateRows || updateRows.affectedRows !== 1) {
-        return { ok: false, msg: '数据库更新失败' }
-    }
-
-    return {
-        ok: true,
-        data: {
-            student_num: studentNum,
-            term_num,
-            total_num,
-            state: 1
-        }
-    }
 }
 
 module.exports = { syncAccountInfo }

+ 221 - 18
lib/Lepao/webvpnCookie.js

@@ -1,13 +1,23 @@
 /**
  * WebVPN 会话 Cookie:调用 ic-ctbu-backend-py /webvpnlogin,并可 Redis 缓存。
  */
+const crypto = require('crypto')
 const axios = require('axios')
 const config = require('../../config.json')
 const db = require('../../plugin/DataBase/db')
 const Redis = require('../../plugin/DataBase/Redis')
 
 const CACHE_PREFIX = 'webvpn_cookie:'
-const CACHE_TTL_SEC = 25 * 60
+const CACHE_TTL_SEC = 10 * 60
+
+class WebVpnLoginError extends Error {
+    constructor(message, { pyCode, pyMsg } = {}) {
+        super(message)
+        this.name = 'WebVpnLoginError'
+        this.pyCode = pyCode
+        this.pyMsg = pyMsg
+    }
+}
 
 function webvpnLoginBaseUrl() {
     const u = config.webvpnLoginBaseUrl || config.url3
@@ -19,68 +29,261 @@ function isProbablyVpnLoginHtml(body) {
     return (
         body.includes('lyuapServer') ||
         body.includes('ivpn') ||
-        body.includes('统一认证') ||
+        body.includes('统一身份认证') ||
         body.includes('rump_frontend/login')
     )
 }
 
-async function fetchWebVpnCookieFromPy(username, password) {
+/**
+ * @param {string} createUserUuid
+ * @param {string} lepaoStudentNum
+ * @returns {Promise<string>}
+ */
+async function resolveUserAgentFromLepaoAccount(createUserUuid, lepaoStudentNum) {
+    if (!createUserUuid || !lepaoStudentNum) return ''
+    const rows = await db.query(
+        'SELECT userAgent FROM lepao_account WHERE create_user = ? AND student_num = ? LIMIT 1',
+        [createUserUuid, lepaoStudentNum]
+    )
+    const ua = rows?.[0]?.userAgent
+    return ua && String(ua).trim() ? String(ua).trim() : ''
+}
+
+/**
+ * @param {string} ua
+ * @returns {string} 拼在 Redis key 后的后缀(含前导冒号)
+ */
+function cacheKeySuffixForUa(ua) {
+    if (!ua) return ':default'
+    const h = crypto.createHash('sha256').update(ua, 'utf8').digest('hex').slice(0, 16)
+    return `:${h}`
+}
+
+/**
+ * @param {unknown} err
+ * @returns {boolean} 是否应将乐跑账号标为 state=3(统一认证凭证类失败)
+ */
+function isWebVpnUnifiedAuthCredentialFailure(err) {
+    const code = err && typeof err.pyCode === 'number' ? err.pyCode : null
+    const msg = String((err && err.pyMsg) || (err && err.message) || '')
+    if (code === 401) return true
+    if (code === 500) {
+        const patterns = ['密码错误', '用户名或密码错误', '用户不存在', '账号或密码', '用户名错误', '帐号或密码']
+        return patterns.some((p) => msg.includes(p))
+    }
+    return false
+}
+
+/**
+ * @param {string} studentNum
+ * @param {string} [createUserUuid]
+ * @param {string} [jwUsername]
+ */
+async function markLepaoUnifiedAuthFailed(studentNum, createUserUuid, jwUsername) {
+    const t = Date.now()
+    await db.query('UPDATE lepao_account SET state = 3, update_time = ? WHERE student_num = ?', [
+        t,
+        studentNum
+    ])
+    if (createUserUuid && jwUsername) {
+        try {
+            await db.query(
+                'UPDATE jw_account SET state = 0, update_time = ? WHERE create_user = ? AND username = ?',
+                [t, createUserUuid, jwUsername]
+            )
+        } catch (_) {
+            /* ignore */
+        }
+        try {
+            await invalidateWebVpnCookie(createUserUuid, jwUsername)
+        } catch (_) {
+            /* ignore */
+        }
+    }
+}
+
+/**
+ * WebVPN 凭证校验成功:将教务绑定标为已启用(与教务「激活」同属 state=1)。
+ * @param {string} createUserUuid
+ * @param {string} jwUsername 学号(教务登录名)
+ */
+async function markJwUnifiedAuthSucceeded(createUserUuid, jwUsername) {
+    const t = Date.now()
+    await db.query(
+        'UPDATE jw_account SET state = 1, update_time = ? WHERE create_user = ? AND username = ?',
+        [t, createUserUuid, jwUsername]
+    )
+}
+
+/**
+ * @param {string} username
+ * @param {string} password
+ * @param {{ userAgent?: string, createUserUuid?: string, lepaoStudentNum?: string }} [opts]
+ */
+async function fetchWebVpnCookieFromPy(username, password, opts = {}) {
     const base = webvpnLoginBaseUrl()
     if (!base) {
         throw new Error('未配置 webvpnLoginBaseUrl')
     }
+    let ua = ''
+    if (opts.userAgent && String(opts.userAgent).trim()) {
+        ua = String(opts.userAgent).trim()
+    } else if (opts.createUserUuid && opts.lepaoStudentNum) {
+        ua = await resolveUserAgentFromLepaoAccount(opts.createUserUuid, opts.lepaoStudentNum)
+    }
     const url = `${base}/webvpnlogin`
-    const res = await axios.post(
-        url,
-        { username, password },
-        { timeout: 120000, proxy: false, validateStatus: () => true }
-    )
+    const body = { username, password }
+    if (ua) body.user_agent = ua
+    const res = await axios.post(url, body, { timeout: 120000, proxy: false, validateStatus: () => true })
     const data = res.data
     if (!data || data.code !== 0 || !data.webvpn_cookie) {
-        throw new Error(data?.msg || 'WebVPN 登录失败')
+        const pyMsg = data?.msg || '统一身份认证登录失败'
+        const pyCode = typeof data?.code === 'number' ? data.code : undefined
+        throw new WebVpnLoginError(pyMsg, { pyCode, pyMsg })
     }
     return String(data.webvpn_cookie)
 }
 
-async function getJwPasswordForUser(uuid, jwUsername) {
+/**
+ * @returns {Promise<{ error: string|null, password?: string }>}
+ */
+async function resolveJwCredentialForLepao(uuid, jwUsername) {
+    const rows = await db.query(
+        'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?',
+        [uuid, jwUsername]
+    )
+    if (!rows || rows.length !== 1 || !rows[0].password) {
+        return {
+            error: '未绑定统一认证账号,请先在乐跑绑定中填写统一认证密码'
+        }
+    }
+    if (Number(rows[0].state) === 2) {
+        return {
+            error: '统一认证账号已标记为失效,请重新绑定教务密码'
+        }
+    }
+    return { error: null, password: rows[0].password }
+}
+
+/**
+ * WebVPN 登录用:仅需已存密码;state 可为 0(待同步)或 1,排除 2(失效)。
+ */
+async function getJwPasswordForWebVpn(uuid, jwUsername) {
     const rows = await db.query(
-        'SELECT password FROM jw_account WHERE create_user = ? AND username = ? AND state IN (0, 1)',
+        'SELECT password, state FROM jw_account WHERE create_user = ? AND username = ?',
         [uuid, jwUsername]
     )
     if (!rows || rows.length !== 1 || !rows[0].password) {
-        throw new Error('未绑定教务账号或账号不可用,请先绑定教务账号')
+        throw new Error('未绑定统一认证账号,请先在乐跑绑定中填写统一认证密码')
+    }
+    if (Number(rows[0].state) === 2) {
+        throw new Error('统一认证账号已标记为失效,请重新绑定教务密码')
     }
     return rows[0].password
 }
 
+async function assertLepaoJwBindingOrThrow(uuid, jwUsername) {
+    const r = await resolveJwCredentialForLepao(uuid, jwUsername)
+    if (r.error) throw new Error(r.error)
+}
+
+async function getJwPasswordForUser(uuid, jwUsername) {
+    const r = await resolveJwCredentialForLepao(uuid, jwUsername)
+    if (r.error) throw new Error(r.error)
+    return r.password
+}
+
+const { syncLepaoStateViaBeforeRun } = require('./lepaoBeforeRunStateSync')
+
 /**
  * @param {string} createUserUuid
  * @param {string} jwUsername 教务登录名
- * @param {{ skipCache?: boolean }} [opts]
+ * @param {{ skipCache?: boolean, skipPostWebVpnLepaoSync?: boolean, logger?: object }} [opts]
  */
 async function getWebVpnCookieHeader(createUserUuid, jwUsername, opts = {}) {
     const skipCache = opts.skipCache === true
-    const key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}`
+    const skipPost = opts.skipPostWebVpnLepaoSync === true
+    const ua = await resolveUserAgentFromLepaoAccount(createUserUuid, jwUsername)
+    const suffix = cacheKeySuffixForUa(ua)
+    const key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}${suffix}`
     if (!skipCache) {
         const cached = await Redis.get(key)
         if (cached) return cached
     }
-    const pwd = await getJwPasswordForUser(createUserUuid, jwUsername)
-    const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd)
+    const pwd = await getJwPasswordForWebVpn(createUserUuid, jwUsername)
+    const cookie = await fetchWebVpnCookieFromPy(jwUsername, pwd, ua ? { userAgent: ua } : {})
     await Redis.set(key, cookie, { EX: CACHE_TTL_SEC })
+
+    await markJwUnifiedAuthSucceeded(createUserUuid, jwUsername)
+
+    if (!skipPost) {
+        const accRows = await db.query(
+            'SELECT uid, token, school_id, userAgent FROM lepao_account WHERE student_num = ? AND create_user = ? LIMIT 1',
+            [jwUsername, createUserUuid]
+        )
+        const conditionSql = 'student_num = ? AND create_user = ?'
+        const queryParams = [jwUsername, createUserUuid]
+        try {
+            await syncLepaoStateViaBeforeRun({
+                studentNum: jwUsername,
+                ownerUuid: createUserUuid,
+                webvpnCookie: cookie,
+                account: accRows?.[0],
+                conditionSql,
+                queryParams,
+                invalidateWebVpn: () => invalidateWebVpnCookie(createUserUuid, jwUsername),
+                refreshWebVpnCookie: async () => {
+                    try {
+                        return await getWebVpnCookieHeader(createUserUuid, jwUsername, {
+                            skipCache: true,
+                            skipPostWebVpnLepaoSync: true,
+                            logger: opts.logger
+                        })
+                    } catch (e) {
+                        if (isWebVpnUnifiedAuthCredentialFailure(e)) {
+                            await markLepaoUnifiedAuthFailed(jwUsername, createUserUuid, jwUsername)
+                        }
+                        throw e
+                    }
+                },
+                logger: opts.logger
+            })
+        } catch (syncErr) {
+            opts.logger?.warn?.(`[WebVPN] 乐跑 token 同步异常 ${jwUsername}: ${syncErr.stack || syncErr}`)
+        }
+    }
+
     return cookie
 }
 
+/**
+ * 清除该用户下该教务账号的 WebVPN Cookie 缓存(含历史无 UA 后缀的 key 与当前 UA 派生 key)。
+ * @param {string} createUserUuid
+ * @param {string} jwUsername
+ */
 async function invalidateWebVpnCookie(createUserUuid, jwUsername) {
-    const key = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}`
-    await Redis.del(key)
+    const legacyKey = `${CACHE_PREFIX}${createUserUuid}:${jwUsername}`
+    await Redis.del(legacyKey)
+    const ua = await resolveUserAgentFromLepaoAccount(createUserUuid, jwUsername)
+    const suffix = cacheKeySuffixForUa(ua)
+    await Redis.del(`${CACHE_PREFIX}${createUserUuid}:${jwUsername}${suffix}`)
+    await Redis.del(`${CACHE_PREFIX}${createUserUuid}:${jwUsername}:default`)
 }
 
 module.exports = {
     webvpnLoginBaseUrl,
     isProbablyVpnLoginHtml,
+    WebVpnLoginError,
     fetchWebVpnCookieFromPy,
+    resolveJwCredentialForLepao,
+    assertLepaoJwBindingOrThrow,
+    getJwPasswordForUser,
+    getJwPasswordForWebVpn,
     getWebVpnCookieHeader,
     invalidateWebVpnCookie,
+    isWebVpnUnifiedAuthCredentialFailure,
+    markLepaoUnifiedAuthFailed,
+    markJwUnifiedAuthSucceeded,
+    resolveUserAgentFromLepaoAccount,
     CACHE_TTL_SEC
 }

+ 28 - 3
plugin/Lepao/runforgeSetZoneProbe.js

@@ -8,7 +8,9 @@ const { dataEncrypt, dataDecrypt, dataSign } = require('./Crypto')
 const {
     getWebVpnCookieHeader,
     invalidateWebVpnCookie,
-    isProbablyVpnLoginHtml
+    isProbablyVpnLoginHtml,
+    isWebVpnUnifiedAuthCredentialFailure,
+    markLepaoUnifiedAuthFailed
 } = require('../../lib/Lepao/webvpnCookie')
 
 const BASE_URL = 'https://lepao.ctbu.edu.cn/v3/api.php'
@@ -81,8 +83,17 @@ async function probeSetZone(p) {
     let webvpnCookie
     if (createUser) {
         try {
-            webvpnCookie = await getWebVpnCookieHeader(createUser, student_num)
+            webvpnCookie = await getWebVpnCookieHeader(createUser, student_num, {
+                skipPostWebVpnLepaoSync: true
+            })
         } catch (e) {
+            if (isWebVpnUnifiedAuthCredentialFailure(e)) {
+                try {
+                    await markLepaoUnifiedAuthFailed(student_num, createUser, student_num)
+                } catch (_) {
+                    /* ignore */
+                }
+            }
             throw new Error(e.message || 'WebVPN 登录失败')
         }
     }
@@ -120,7 +131,21 @@ async function probeSetZone(p) {
         createUser
     ) {
         await invalidateWebVpnCookie(createUser, student_num)
-        webvpnCookie = await getWebVpnCookieHeader(createUser, student_num, { skipCache: true })
+        try {
+            webvpnCookie = await getWebVpnCookieHeader(createUser, student_num, {
+                skipCache: true,
+                skipPostWebVpnLepaoSync: true
+            })
+        } catch (vpnRefreshErr) {
+            if (isWebVpnUnifiedAuthCredentialFailure(vpnRefreshErr)) {
+                try {
+                    await markLepaoUnifiedAuthFailed(student_num, createUser, student_num)
+                } catch (_) {
+                    /* ignore */
+                }
+            }
+            throw new Error(vpnRefreshErr.message || 'WebVPN 登录失败')
+        }
         res = await postOnce()
         try {
             result = JSON.parse(res.data)