Browse Source

✨ feat: 增加选课功能

Pchen. 1 month ago
parent
commit
20ff84fb50

+ 175 - 0
apis/QK/Admin/Admin.js

@@ -0,0 +1,175 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+function fail(res, err, fallback = '后台抢课管理操作失败') {
+    return res.json({
+        ...BaseStdResponse.ERR,
+        msg: err?.message || fallback
+    })
+}
+
+class ListClient extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Client/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const list = await scheduler.listClients()
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课客户端列表失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课客户端列表失败')
+        }
+    }
+}
+
+class DeleteClient extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Client/Delete')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { client_id } = req.body
+            if (!client_id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.deleteClient(client_id)
+            this.logger.info(`[QK][API][DeleteClient] 已禁用客户端 clientId=${client_id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`禁用抢课客户端失败:${err.stack || err}`)
+            return fail(res, err, '禁用抢课客户端失败')
+        }
+    }
+}
+
+class ListTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const result = await scheduler.listAdminTasks(req.query)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`获取抢课任务列表失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务列表失败')
+        }
+    }
+}
+
+class GetTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.query
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            const task = await scheduler.getAdminTask(id)
+            if (!task) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '任务不存在' })
+            }
+            return res.json({ ...BaseStdResponse.OK, data: task })
+        } catch (err) {
+            this.logger.error(`获取抢课任务详情失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务详情失败')
+        }
+    }
+}
+
+class UpdateTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Update')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminUpdateTask(id, req.body)
+            this.logger.info(`[QK][API][AdminUpdateTask] 管理员更新任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员更新抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '更新抢课任务失败')
+        }
+    }
+}
+
+class CancelTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Cancel')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminCancelTask(id)
+            this.logger.info(`[QK][API][AdminCancelTask] 管理员取消任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员取消抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '取消抢课任务失败')
+        }
+    }
+}
+
+class RetryTask extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Task/Retry')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.adminRetryTask(id)
+            this.logger.info(`[QK][API][AdminRetryTask] 管理员重试任务 taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`管理员重试抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '重试抢课任务失败')
+        }
+    }
+}
+
+module.exports = {
+    ListClient,
+    DeleteClient,
+    ListTask,
+    GetTask,
+    UpdateTask,
+    CancelTask,
+    RetryTask
+}

+ 153 - 0
apis/QK/Client/Client.js

@@ -0,0 +1,153 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+function getClientCredentials(body) {
+    return {
+        clientId: body.client_id,
+        clientSecret: body.client_secret
+    }
+}
+
+function fail(res, err, fallback = '客户端请求失败') {
+    return res.json({
+        ...BaseStdResponse.ERR,
+        msg: err?.message || fallback
+    })
+}
+
+class RegisterClient extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/Register')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.registerClient(clientId, clientSecret, req.body)
+            this.logger.info(`[QK][API][Register] 客户端注册成功 clientId=${result.client_id} max_slots=${result.max_slots}`)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端注册失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端注册失败')
+        }
+    }
+}
+
+class HeartbeatClient extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/Heartbeat')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.heartbeat(clientId, clientSecret, req.body)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端心跳失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端心跳失败')
+        }
+    }
+}
+
+class PullTasks extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/PullTasks')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const tasks = await scheduler.pullTasks(clientId, clientSecret, req.body.count)
+            if (tasks.length > 0) {
+                this.logger.info(`[QK][API][PullTasks] clientId=${clientId} 拉取到 ${tasks.length} 个任务 taskIds=${tasks.map(t => t.id).join(',')}`)
+            }
+            return res.json({ ...BaseStdResponse.OK, data: tasks })
+        } catch (err) {
+            this.logger.error(`抢课客户端拉取任务失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端拉取任务失败')
+        }
+    }
+}
+
+class ReclaimTasks extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReclaimTasks')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const tasks = await scheduler.reclaimTasks(clientId, clientSecret)
+            if (tasks.length > 0) {
+                this.logger.info(`[QK][API][ReclaimTasks] clientId=${clientId} 回收 ${tasks.length} 个任务 taskIds=${tasks.map(t => t.id).join(',')}`)
+            }
+            return res.json({ ...BaseStdResponse.OK, data: tasks })
+        } catch (err) {
+            this.logger.error(`抢课客户端回收任务失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端回收任务失败')
+        }
+    }
+}
+
+class ReleaseTasks extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReleaseTasks')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.releaseTasks(clientId, clientSecret, req.body)
+            if (result.released > 0) {
+                this.logger.info(`[QK][API][ReleaseTasks] clientId=${clientId} 释放 ${result.released} 个任务 taskIds=${result.task_ids.join(',')}`)
+            }
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端释放任务失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端释放任务失败')
+        }
+    }
+}
+
+class ReportResult extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReportResult')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.reportResult(clientId, clientSecret, req.body)
+            this.logger.info(`[QK][API][ReportResult] clientId=${clientId} taskId=${result.task_id} status=${result.status}`)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`抢课客户端上报结果失败:${err.stack || err}`)
+            return fail(res, err, '抢课客户端上报结果失败')
+        }
+    }
+}
+
+module.exports = {
+    RegisterClient,
+    HeartbeatClient,
+    PullTasks,
+    ReclaimTasks,
+    ReleaseTasks,
+    ReportResult
+}

+ 0 - 65
apis/QK/Rule/AddRule.js

@@ -1,65 +0,0 @@
-const API = require("../../../lib/API");
-const db = require("../../../plugin/DataBase/db");
-const AccessControl = require("../../../lib/AccessControl");
-const { BaseStdResponse } = require("../../../BaseStdResponse");
-
-class AddRule extends API {
-    constructor() {
-        super()
-
-        this.setPath('/QK/Rule')
-        this.setMethod('POST')
-    }
-
-    async onRequest(req, res) {
-        let {
-            uuid,
-            session,
-            id,
-            name,
-            account,
-            crouse,
-            state
-        } = req.body
-
-        if ([uuid, session, name, account, crouse].some(value => value === '' || value === null || value === undefined) || (loop && !day_of_week))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER
-            })
-
-        // 检查 session
-        if (!await AccessControl.checkSession(uuid, session))
-            return res.status(401).json({
-                ...BaseStdResponse.ACCESS_DENIED
-            })
-
-        let sql, r
-        const time = new Date().getTime()
-
-        if (!id) {
-            sql = 'INSERT INTO qk_rule (\`name\`, create_user, create_time, account, crouse) VALUES (?, ?, ?, ?, ?)'
-            r = await db.query(sql, [name, uuid, time, account, crouse])
-        } else {
-            sql = 'UPDATE qk_rule SET \`name\` = ?, account = ?, crouse = ?, update_time = ?, \`state\` = ? WHERE id = ? AND create_user = ?'
-            r = await db.query(sql, [name, account, crouse, time, state, id, uuid])
-        }
-
-        try {
-            if (r && r.affectedRows > 0) {
-                res.json({
-                    ...BaseStdResponse.OK
-                })
-            } else {
-                res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '添加规则失败!数据库错误' })
-            }
-        } catch (err) {
-            this.logger.error(`添加规则失败!${err.stack}`)
-            res.json({
-                ...BaseStdResponse.ERR,
-                msg: "添加规则失败!",
-            });
-        }
-    }
-}
-
-module.exports.AddRule = AddRule;

+ 0 - 49
apis/QK/Rule/DeleteRule.js

@@ -1,49 +0,0 @@
-const API = require("../../../lib/API");
-const db = require("../../../plugin/DataBase/db");
-const AccessControl = require("../../../lib/AccessControl");
-const { BaseStdResponse } = require("../../../BaseStdResponse");
-
-class DeleteRule extends API {
-    constructor() {
-        super();
-
-        this.setPath('/QK/Rule')
-        this.setMethod('DELETE')
-    }
-
-    async onRequest(req, res) {
-        let { uuid, session, id } = req.body
-
-        if ([uuid, session, id].some(value => value === '' || value === null || value === undefined))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER
-            })
-
-        // 检查 session
-        if (!await AccessControl.checkSession(uuid, session))
-            return res.status(401).json({
-                ...BaseStdResponse.ACCESS_DENIED
-            })
-
-        let sql = 'DELETE FROM qk_rule WHERE id = ? AND create_user = ?'
-        let r = await db.query(sql, [id, uuid])
-
-        try {
-            if (r && r.affectedRows > 0) {
-                res.json({
-                    ...BaseStdResponse.OK
-                })
-            } else {
-                res.json({ ...BaseStdResponse.ERR, endpoint: 7894378, msg: '删除规则失败!数据库错误' })
-            }
-        } catch (err) {
-            this.logger.error(`删除规则失败!${err.stack}`)
-            res.json({
-                ...BaseStdResponse.ERR,
-                msg: "删除规则失败!",
-            });
-        }
-    }
-}
-
-module.exports.DeleteRule = DeleteRule

+ 0 - 47
apis/QK/Rule/GetRule.js

@@ -1,47 +0,0 @@
-const API = require("../../../lib/API");
-const db = require("../../../plugin/DataBase/db");
-const AccessControl = require("../../../lib/AccessControl");
-const { BaseStdResponse } = require("../../../BaseStdResponse");
-
-class GetRule extends API {
-    constructor() {
-        super();
-
-        this.setPath('/QK/Rule')
-        this.setMethod('GET')
-    }
-
-    async onRequest(req, res) {
-        let {
-            uuid,
-            session
-        } = req.query
-
-        if ([uuid, session].some(value => value === '' || value === null || value === undefined))
-            return res.json({
-                ...BaseStdResponse.MISSING_PARAMETER
-            })
-
-        // 检查 session
-        if (!await AccessControl.checkSession(uuid, session))
-            return res.status(401).json({
-                ...BaseStdResponse.ACCESS_DENIED
-            })
-
-        let sql = 'SELECT * FROM qk_rule WHERE create_user = ?'
-        let rows = await db.query(sql, [uuid])
-
-        if (!rows)
-            return res.json({
-                ...BaseStdResponse.MISSING_FILE,
-                msg: '获取规则列表失败!'
-            })
-
-        res.json({
-            ...BaseStdResponse.OK,
-            data: rows
-        })
-    }
-}
-
-module.exports.GetRule = GetRule;

+ 117 - 0
apis/QK/Task/Task.js

@@ -0,0 +1,117 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+function fail(res, err, fallback = '抢课任务操作失败') {
+    return res.json({
+        ...BaseStdResponse.ERR,
+        msg: err?.message || fallback
+    })
+}
+
+class GetTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid } = req.query
+            const list = await scheduler.listUserTasks(uuid)
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务失败')
+        }
+    }
+}
+
+class GetTaskDetail extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.query
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            const detail = await scheduler.getTaskDetail(uuid, id)
+            if (!detail) {
+                return res.json({ ...BaseStdResponse.ERR, msg: '任务不存在' })
+            }
+            return res.json({ ...BaseStdResponse.OK, data: detail })
+        } catch (err) {
+            this.logger.error(`获取抢课任务详情失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课任务详情失败')
+        }
+    }
+}
+
+class SaveTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id, name, jx0502zbid, student_num, password } = req.body
+            if ([uuid, name, jx0502zbid, 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: '新建任务需要填写教务密码' })
+            }
+            if (id) {
+                await scheduler.updateTask(uuid, id, req.body)
+                this.logger.info(`[QK][API][SaveTask] 用户更新任务 uuid=${uuid} taskId=${id}`)
+                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}`)
+            return res.json({ ...BaseStdResponse.OK, data: { id: taskId } })
+        } catch (err) {
+            this.logger.error(`保存抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '保存抢课任务失败')
+        }
+    }
+}
+
+class DeleteTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task')
+        this.setMethod('DELETE')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.cancelTask(uuid, id)
+            this.logger.info(`[QK][API][DeleteTask] 用户取消任务 uuid=${uuid} taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`取消抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '取消抢课任务失败')
+        }
+    }
+}
+
+module.exports = {
+    GetTask,
+    GetTaskDetail,
+    SaveTask,
+    DeleteTask
+}

+ 10 - 0
config.example.json

@@ -23,6 +23,16 @@
         "lepaoScheduleBatch": 100,
         "lepaoScheduleBatch": 100,
         "mqPrefix": ""
         "mqPrefix": ""
     },
     },
+    "qk": {
+        "passwordAesKey": "CHANGE_ME_TO_A_RANDOM_SECRET",
+        "leaseMs": 90000,
+        "heartbeatTtlSeconds": 45,
+        "leaseWatcherIntervalMs": 30000,
+        "staleTaskMs": 3600000,
+        "memPerSlotMb": 3072,
+        "memReserveMb": 1024,
+        "maxSlotsCap": 10
+    },
     "email": [
     "email": [
         {
         {
             "host": "smtp.primary.example.com",
             "host": "smtp.primary.example.com",

+ 23 - 0
lib/PermissionCatalog.js

@@ -10,6 +10,9 @@ const DEFAULT_PERMISSION_POINTS = [
     { code: 'page.admin.goods.sendCountRequestList', name: '赠送审核', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.goods.sendCountRequestList', remark: '访问乐跑次数赠送审核页面' },
     { code: 'page.admin.goods.sendCountRequestList', name: '赠送审核', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.goods.sendCountRequestList', remark: '访问乐跑次数赠送审核页面' },
     { code: 'page.service.createOrder', name: '提交工单', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'service.createOrder', remark: '访问用户提交工单页面' },
     { code: 'page.service.createOrder', name: '提交工单', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'service.createOrder', remark: '访问用户提交工单页面' },
     { code: 'page.lepao.accountList', name: '乐跑账号', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'lepao.accountList', remark: '访问用户乐跑账号页面' },
     { code: 'page.lepao.accountList', name: '乐跑账号', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'lepao.accountList', remark: '访问用户乐跑账号页面' },
+    { code: 'page.qk.taskList', name: '抢课助手', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.qkAssistant', remark: '管理员访问抢课助手页面' },
+    { code: 'page.admin.qk.client', name: '抢课客户端管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.qkClient', remark: '访问后台抢课客户端管理页面' },
+    { code: 'page.admin.qk.task', name: '抢课任务管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.qkTask', remark: '访问后台抢课任务管理页面' },
 
 
     { code: 'action.user.changeCount', name: '更改乐跑次数', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员调整用户乐跑次数' },
     { code: 'action.user.changeCount', name: '更改乐跑次数', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员调整用户乐跑次数' },
     { code: 'action.user.ban', name: '封禁账户', category: PermissionCategory.ACTION, scope_type: 'action', remark: '封禁或解封用户账户' },
     { code: 'action.user.ban', name: '封禁账户', category: PermissionCategory.ACTION, scope_type: 'action', remark: '封禁或解封用户账户' },
@@ -21,6 +24,9 @@ const DEFAULT_PERMISSION_POINTS = [
     { code: 'action.lepao.updateAccount', name: '更新乐跑账号', category: PermissionCategory.ACTION, scope_type: 'action', remark: '更新乐跑账号信息' },
     { code: 'action.lepao.updateAccount', name: '更新乐跑账号', category: PermissionCategory.ACTION, scope_type: 'action', remark: '更新乐跑账号信息' },
     { code: 'action.lepao.deleteAccount', name: '解绑乐跑账号', category: PermissionCategory.ACTION, scope_type: 'action', remark: '解绑乐跑账号' },
     { code: 'action.lepao.deleteAccount', name: '解绑乐跑账号', category: PermissionCategory.ACTION, scope_type: 'action', remark: '解绑乐跑账号' },
     { code: 'action.lepao.admin.updateAccount', name: '后台更新乐跑账号', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员更新乐跑账号信息' },
     { code: 'action.lepao.admin.updateAccount', name: '后台更新乐跑账号', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员更新乐跑账号信息' },
+    { code: 'action.qk.task.submit', name: '提交抢课任务', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员提交或更新抢课任务' },
+    { code: 'action.qk.admin.clientManage', name: '管理抢课客户端', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员禁用抢课客户端' },
+    { code: 'action.qk.admin.taskManage', name: '管理抢课任务', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员编辑、重试或取消抢课任务' },
     { code: 'action.goods.sendCount', name: '赠送乐跑次数', category: PermissionCategory.ACTION, scope_type: 'action', remark: '用户向他人赠送乐跑次数' },
     { code: 'action.goods.sendCount', name: '赠送乐跑次数', category: PermissionCategory.ACTION, scope_type: 'action', remark: '用户向他人赠送乐跑次数' },
     { code: 'action.goods.reviewSendCount', name: '审核赠送次数', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员审核乐跑次数赠送申请' },
     { code: 'action.goods.reviewSendCount', name: '审核赠送次数', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员审核乐跑次数赠送申请' },
     { code: 'action.service.createOrder', name: '发起工单', category: PermissionCategory.ACTION, scope_type: 'action', remark: '用户提交新工单或回复自己的工单' },
     { code: 'action.service.createOrder', name: '发起工单', category: PermissionCategory.ACTION, scope_type: 'action', remark: '用户提交新工单或回复自己的工单' },
@@ -34,6 +40,9 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { resource_type: 'page', resource_key: 'admin.goods.sendCountRequestList', required_codes: ['page.admin.goods.sendCountRequestList'] },
     { resource_type: 'page', resource_key: 'admin.goods.sendCountRequestList', required_codes: ['page.admin.goods.sendCountRequestList'] },
     { resource_type: 'page', resource_key: 'service.createOrder', required_codes: ['page.service.createOrder'] },
     { resource_type: 'page', resource_key: 'service.createOrder', required_codes: ['page.service.createOrder'] },
     { resource_type: 'page', resource_key: 'lepao.accountList', required_codes: ['page.lepao.accountList'] },
     { resource_type: 'page', resource_key: 'lepao.accountList', required_codes: ['page.lepao.accountList'] },
+    { resource_type: 'page', resource_key: 'admin.qkAssistant', required_codes: ['page.qk.taskList'] },
+    { resource_type: 'page', resource_key: 'admin.qkClient', required_codes: ['page.admin.qk.client'] },
+    { resource_type: 'page', resource_key: 'admin.qkTask', required_codes: ['page.admin.qk.task'] },
 
 
     { resource_type: 'action', resource_key: 'action.user.changeCount', required_codes: ['action.user.changeCount'] },
     { resource_type: 'action', resource_key: 'action.user.changeCount', required_codes: ['action.user.changeCount'] },
     { resource_type: 'action', resource_key: 'action.user.ban', required_codes: ['action.user.ban'] },
     { resource_type: 'action', resource_key: 'action.user.ban', required_codes: ['action.user.ban'] },
@@ -45,6 +54,9 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { resource_type: 'action', resource_key: 'action.lepao.updateAccount', required_codes: ['action.lepao.updateAccount'] },
     { resource_type: 'action', resource_key: 'action.lepao.updateAccount', required_codes: ['action.lepao.updateAccount'] },
     { resource_type: 'action', resource_key: 'action.lepao.deleteAccount', required_codes: ['action.lepao.deleteAccount'] },
     { resource_type: 'action', resource_key: 'action.lepao.deleteAccount', required_codes: ['action.lepao.deleteAccount'] },
     { resource_type: 'action', resource_key: 'action.lepao.admin.updateAccount', required_codes: ['action.lepao.admin.updateAccount'] },
     { resource_type: 'action', resource_key: 'action.lepao.admin.updateAccount', required_codes: ['action.lepao.admin.updateAccount'] },
+    { resource_type: 'action', resource_key: 'action.qk.task.submit', required_codes: ['action.qk.task.submit'] },
+    { resource_type: 'action', resource_key: 'action.qk.admin.clientManage', required_codes: ['action.qk.admin.clientManage'] },
+    { resource_type: 'action', resource_key: 'action.qk.admin.taskManage', required_codes: ['action.qk.admin.taskManage'] },
     { resource_type: 'action', resource_key: 'action.goods.sendCount', required_codes: ['action.goods.sendCount'] },
     { resource_type: 'action', resource_key: 'action.goods.sendCount', required_codes: ['action.goods.sendCount'] },
     { resource_type: 'action', resource_key: 'action.goods.reviewSendCount', required_codes: ['action.goods.reviewSendCount'] },
     { resource_type: 'action', resource_key: 'action.goods.reviewSendCount', required_codes: ['action.goods.reviewSendCount'] },
     { resource_type: 'action', resource_key: 'action.service.createOrder', required_codes: ['action.service.createOrder'] },
     { resource_type: 'action', resource_key: 'action.service.createOrder', required_codes: ['action.service.createOrder'] },
@@ -61,6 +73,17 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { resource_type: 'api', resource_key: 'GET /Lepao/ChangeAutoRun', api_method: 'GET', api_path: '/Lepao/ChangeAutoRun', required_codes: ['action.lepao.changeAutoRun'] },
     { resource_type: 'api', resource_key: 'GET /Lepao/ChangeAutoRun', api_method: 'GET', api_path: '/Lepao/ChangeAutoRun', required_codes: ['action.lepao.changeAutoRun'] },
     { resource_type: 'api', resource_key: 'POST /Lepao/Account/UpdateSelfAccount', api_method: 'POST', api_path: '/Lepao/Account/UpdateSelfAccount', required_codes: ['action.lepao.updateAccount'] },
     { resource_type: 'api', resource_key: 'POST /Lepao/Account/UpdateSelfAccount', api_method: 'POST', api_path: '/Lepao/Account/UpdateSelfAccount', required_codes: ['action.lepao.updateAccount'] },
     { resource_type: 'api', resource_key: 'DELETE /Lepao/Account', api_method: 'DELETE', api_path: '/Lepao/Account', required_codes: ['action.lepao.deleteAccount'] },
     { resource_type: 'api', resource_key: 'DELETE /Lepao/Account', api_method: 'DELETE', api_path: '/Lepao/Account', required_codes: ['action.lepao.deleteAccount'] },
+    { resource_type: 'api', resource_key: 'GET /QK/Task', api_method: 'GET', api_path: '/QK/Task', required_codes: ['page.qk.taskList'] },
+    { resource_type: 'api', resource_key: 'GET /QK/Task/Detail', api_method: 'GET', api_path: '/QK/Task/Detail', required_codes: ['page.qk.taskList'] },
+    { resource_type: 'api', resource_key: 'POST /QK/Task', api_method: 'POST', api_path: '/QK/Task', required_codes: ['action.qk.task.submit'] },
+    { resource_type: 'api', resource_key: 'DELETE /QK/Task', api_method: 'DELETE', api_path: '/QK/Task', required_codes: ['action.qk.task.submit'] },
+    { resource_type: 'api', resource_key: 'GET /Admin/QK/Client/List', api_method: 'GET', api_path: '/Admin/QK/Client/List', required_codes: ['page.admin.qk.client'] },
+    { resource_type: 'api', resource_key: 'DELETE /Admin/QK/Client/Delete', api_method: 'DELETE', api_path: '/Admin/QK/Client/Delete', required_codes: ['action.qk.admin.clientManage'] },
+    { resource_type: 'api', resource_key: 'GET /Admin/QK/Task/List', api_method: 'GET', api_path: '/Admin/QK/Task/List', required_codes: ['page.admin.qk.task'] },
+    { resource_type: 'api', resource_key: 'GET /Admin/QK/Task/Detail', api_method: 'GET', api_path: '/Admin/QK/Task/Detail', required_codes: ['page.admin.qk.task'] },
+    { resource_type: 'api', resource_key: 'POST /Admin/QK/Task/Update', api_method: 'POST', api_path: '/Admin/QK/Task/Update', required_codes: ['action.qk.admin.taskManage'] },
+    { resource_type: 'api', resource_key: 'DELETE /Admin/QK/Task/Cancel', api_method: 'DELETE', api_path: '/Admin/QK/Task/Cancel', required_codes: ['action.qk.admin.taskManage'] },
+    { resource_type: 'api', resource_key: 'POST /Admin/QK/Task/Retry', api_method: 'POST', api_path: '/Admin/QK/Task/Retry', required_codes: ['action.qk.admin.taskManage'] },
     { resource_type: 'api', resource_key: 'POST /Goods/SendCount', api_method: 'POST', api_path: '/Goods/SendCount', required_codes: ['action.goods.sendCount'] },
     { resource_type: 'api', resource_key: 'POST /Goods/SendCount', api_method: 'POST', api_path: '/Goods/SendCount', required_codes: ['action.goods.sendCount'] },
     { resource_type: 'api', resource_key: 'POST /Kefu/Order', api_method: 'POST', api_path: '/Kefu/Order', required_codes: ['action.service.createOrder'] },
     { resource_type: 'api', resource_key: 'POST /Kefu/Order', api_method: 'POST', api_path: '/Kefu/Order', required_codes: ['action.service.createOrder'] },
     { resource_type: 'api', resource_key: 'GET /Admin/Kefu/Order', api_method: 'GET', api_path: '/Admin/Kefu/Order', required_codes: ['page.admin.service.orderList'] },
     { resource_type: 'api', resource_key: 'GET /Admin/Kefu/Order', api_method: 'GET', api_path: '/Admin/Kefu/Order', required_codes: ['page.admin.service.orderList'] },

+ 50 - 0
lib/QK/LeaseWatcher.js

@@ -0,0 +1,50 @@
+const Logger = require('../Logger')
+const { TaskScheduler } = require('./TaskScheduler')
+
+class LeaseWatcher {
+    constructor(options = {}) {
+        this.intervalMs = options.intervalMs || 30 * 1000
+        this.logger = options.logger || new Logger()
+        this.scheduler = options.scheduler || new TaskScheduler()
+        this.timer = null
+        this.running = false
+    }
+
+    async tick() {
+        if (this.running) {
+            this.logger.warn('[QK][LeaseWatcher] 上一轮巡检尚未结束,跳过本次执行')
+            return
+        }
+        this.running = true
+        try {
+            const count = await this.scheduler.requeueExpiredTasks()
+            if (count > 0) {
+                this.logger.info(`[QK][LeaseWatcher] 已将 ${count} 个失联任务重新入队`)
+            }
+        } catch (err) {
+            this.logger.error(`[QK][LeaseWatcher] 执行失败:${err.stack || err}`)
+        } finally {
+            this.running = false
+        }
+    }
+
+    start() {
+        if (this.timer) {
+            return this.timer
+        }
+        this.logger.info(`[QK][LeaseWatcher] 已启动,巡检间隔 ${this.intervalMs}ms`)
+        this.tick()
+        this.timer = setInterval(() => this.tick(), this.intervalMs)
+        return this.timer
+    }
+
+    stop() {
+        if (this.timer) {
+            clearInterval(this.timer)
+            this.timer = null
+            this.logger.info('[QK][LeaseWatcher] 已停止')
+        }
+    }
+}
+
+module.exports = LeaseWatcher

+ 940 - 0
lib/QK/TaskScheduler.js

@@ -0,0 +1,940 @@
+const crypto = require('crypto')
+const bcryptjs = require('bcryptjs')
+const config = require('../../config.json')
+const db = require('../../plugin/DataBase/db')
+const Redis = require('../../plugin/DataBase/Redis')
+const Logger = require('../Logger')
+
+const TASK_STATUS = {
+    PENDING: 'pending',
+    ASSIGNED: 'assigned',
+    RUNNING: 'running',
+    SUCCESS: 'success',
+    FAILED: 'failed',
+    CANCELLED: 'cancelled'
+}
+
+class TaskScheduler {
+    constructor(options = {}) {
+        this.leaseMs = options.leaseMs || config.qk?.leaseMs || 90 * 1000
+        this.heartbeatTtlSeconds = options.heartbeatTtlSeconds || config.qk?.heartbeatTtlSeconds || 45
+        this.pullLockTtlSeconds = options.pullLockTtlSeconds || 5
+        this.staleTaskMs = options.staleTaskMs || config.qk?.staleTaskMs || 60 * 60 * 1000
+        this.memPerSlotMb = options.memPerSlotMb || config.qk?.memPerSlotMb || 3072
+        this.memReserveMb = options.memReserveMb || config.qk?.memReserveMb || 1024
+        this.maxSlotsCap = options.maxSlotsCap || config.qk?.maxSlotsCap || 10
+        this.logger = options.logger || new Logger()
+    }
+
+    calculateMaxSlots(profile = {}) {
+        const freeMb = Math.max(0, Number(profile.free_mem_mb) || 0)
+        const totalMb = Math.max(0, Number(profile.total_mem_mb) || 0)
+        const threads = Math.max(1, Number(profile.cpu_threads) || 1)
+        const allocatableFreeMb = Math.max(0, freeMb - this.memReserveMb)
+        const allocatableTotalMb = Math.max(0, totalMb - this.memReserveMb)
+        const byFreeMem = Math.floor(allocatableFreeMb / this.memPerSlotMb)
+        const byTotalMem = Math.floor(allocatableTotalMb / this.memPerSlotMb)
+        const byCpu = Math.floor(threads * 0.8)
+        const fallbackMem = totalMb > 0 ? byTotalMem : 1
+        const byMem = freeMb > 0 ? Math.min(byFreeMem, byTotalMem) : fallbackMem
+        return Math.max(1, Math.min(50, this.maxSlotsCap, byMem, byCpu))
+    }
+
+    resolveClientMaxSlots(client, payload = {}) {
+        const fromPayload = this.calculateMaxSlots({
+            free_mem_mb: payload.free_mem_mb ?? client.free_mem_mb,
+            total_mem_mb: payload.total_mem_mb ?? client.total_mem_mb,
+            cpu_threads: payload.cpu_threads ?? client.cpu_threads
+        })
+        const reported = Number(payload.max_slots || client.max_slots || 0)
+        if (!reported) return fromPayload
+        return Math.max(1, Math.min(reported, fromPayload))
+    }
+
+    getClientAvailableSlots(client, payload = {}) {
+        const maxSlots = this.resolveClientMaxSlots(client, payload)
+        const currentSlots = Math.max(0, Number(payload.current_slots ?? client.current_slots ?? 0))
+        return Math.max(0, maxSlots - currentSlots)
+    }
+
+    async countOnlineClients() {
+        const now = Date.now()
+        const threshold = now - this.heartbeatTtlSeconds * 1000
+        const rows = await db.query(
+            `SELECT COUNT(*) AS total FROM qk_client
+            WHERE enabled = 1 AND online = 1
+            AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at >= ?`,
+            [threshold]
+        )
+        return Number(rows?.[0]?.total || 0)
+    }
+
+    safeStringify(obj) {
+        const seen = new WeakSet()
+        return JSON.stringify(obj, (key, value) => {
+            if (typeof value === 'object' && value !== null) {
+                if (seen.has(value)) return '[Circular]'
+                seen.add(value)
+            }
+            return value
+        })
+    }
+
+    sanitizeForLog(payload) {
+        if (!payload || typeof payload !== 'object') {
+            return payload
+        }
+        const copy = Array.isArray(payload) ? [...payload] : { ...payload }
+        for (const key of ['password', 'pass', 'password_enc', 'client_secret']) {
+            if (key in copy) {
+                copy[key] = '***'
+            }
+        }
+        return copy
+    }
+
+    buildLogPrefix(tag, ctx = {}) {
+        const parts = ['[QK]', `[${tag}]`]
+        if (ctx.taskId) parts.push(`[taskId=${ctx.taskId}]`)
+        if (ctx.clientId) parts.push(`[clientId=${ctx.clientId}]`)
+        if (ctx.uuid) parts.push(`[uuid=${ctx.uuid}]`)
+        return parts.join('')
+    }
+
+    logInfo(tag, message, ctx = {}, data = null) {
+        const prefix = this.buildLogPrefix(tag, ctx)
+        const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
+        this.logger.info(`${prefix} ${message}${suffix}`)
+    }
+
+    logWarn(tag, message, ctx = {}, data = null) {
+        const prefix = this.buildLogPrefix(tag, ctx)
+        const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
+        this.logger.warn(`${prefix} ${message}${suffix}`)
+    }
+
+    logError(tag, message, ctx = {}, err = null) {
+        const prefix = this.buildLogPrefix(tag, ctx)
+        const suffix = err ? ` ${err.stack || err}` : ''
+        this.logger.error(`${prefix} ${message}${suffix}`)
+    }
+
+    getPasswordKey() {
+        const source = process.env.QK_PASSWORD_KEY || config.qk?.passwordAesKey || config.database?.password || 'runforge-qk-default-key'
+        return crypto.createHash('sha256').update(String(source)).digest()
+    }
+
+    encryptPassword(password) {
+        const iv = crypto.randomBytes(16)
+        const cipher = crypto.createCipheriv('aes-256-cbc', this.getPasswordKey(), iv)
+        let encrypted = cipher.update(String(password), 'utf8', 'base64')
+        encrypted += cipher.final('base64')
+        return `${iv.toString('base64')}:${encrypted}`
+    }
+
+    decryptPassword(encrypted) {
+        const [ivText, payload] = String(encrypted || '').split(':')
+        if (!ivText || !payload) {
+            return ''
+        }
+        const decipher = crypto.createDecipheriv('aes-256-cbc', this.getPasswordKey(), Buffer.from(ivText, 'base64'))
+        let decrypted = decipher.update(payload, 'base64', 'utf8')
+        decrypted += decipher.final('utf8')
+        return decrypted
+    }
+
+    normalizeArray(value) {
+        if (Array.isArray(value)) {
+            return value.map(item => String(item).trim()).filter(Boolean)
+        }
+        if (typeof value === 'string') {
+            const trimmed = value.trim()
+            if (!trimmed) {
+                return []
+            }
+            try {
+                const parsed = JSON.parse(trimmed)
+                if (Array.isArray(parsed)) {
+                    return parsed.map(item => String(item).trim()).filter(Boolean)
+                }
+            } catch (_) {
+                return trimmed.split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
+            }
+        }
+        return []
+    }
+
+    serializeTask(row, includeSecret = false) {
+        const result = { ...row }
+        result.enable_ggxxk = Number(result.enable_ggxxk) === 1
+        result.courses = this.normalizeArray(result.courses)
+        result.course_groups = this.normalizeArray(result.course_groups)
+        if (typeof result.result_json === 'string' && result.result_json) {
+            try {
+                result.result_json = JSON.parse(result.result_json)
+            } catch (_) {}
+        }
+        if (includeSecret) {
+            result.password = this.decryptPassword(result.password_enc)
+        }
+        delete result.password_enc
+        return result
+    }
+
+    async logTask(taskId, clientId, event, message = '', payload = null) {
+        const sql = 'INSERT INTO qk_task_log (task_id, client_id, event, message, payload_json, create_time) VALUES (?, ?, ?, ?, ?, ?)'
+        await db.query(sql, [
+            taskId,
+            clientId || null,
+            event,
+            message || '',
+            payload ? JSON.stringify(payload) : null,
+            Date.now()
+        ])
+        this.logInfo('taskLog', message || event, { taskId, clientId }, payload ? { event, ...this.sanitizeForLog(payload) } : { event })
+    }
+
+    async createTask(uuid, payload) {
+        const courses = this.normalizeArray(payload.courses || payload.COURSES)
+        const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
+        const intervalMs = Number(payload.interval_ms || payload.INTERVAL_MS || 500)
+        if (!courses.length && !courseGroups.length) {
+            throw new Error('至少需要填写一门课程或一个课程分组')
+        }
+        if (!Number.isFinite(intervalMs) || intervalMs < 200 || intervalMs > 10000) {
+            throw new Error('抢课间隔需在 200-10000ms 之间')
+        }
+        const time = Date.now()
+        const sql = `INSERT INTO qk_task
+            (create_user, name, jx0502zbid, student_num, password_enc, courses, course_groups, enable_ggxxk, interval_ms, status, create_time, update_time)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+        const result = await db.query(sql, [
+            uuid,
+            payload.name,
+            payload.jx0502zbid || payload.id,
+            payload.student_num || payload.user,
+            this.encryptPassword(payload.password || payload.pass),
+            JSON.stringify(courses),
+            JSON.stringify(courseGroups),
+            payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
+            intervalMs,
+            TASK_STATUS.PENDING,
+            time,
+            time
+        ])
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('创建抢课任务失败')
+        }
+        await this.logTask(result.insertId, null, 'created', '用户提交抢课任务')
+        this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
+            name: payload.name,
+            student_num: payload.student_num || payload.user,
+            jx0502zbid: payload.jx0502zbid || payload.id,
+            courses_count: courses.length,
+            course_groups_count: courseGroups.length,
+            interval_ms: intervalMs,
+            enable_ggxxk: !!(payload.enable_ggxxk || payload.ENABLE_GGXXK)
+        })
+        return result.insertId
+    }
+
+    async updateTask(uuid, taskId, payload) {
+        const rows = await db.query('SELECT status FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
+        if (!rows || rows.length === 0) {
+            throw new Error('任务不存在')
+        }
+        if (![TASK_STATUS.PENDING, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
+            throw new Error('任务已被客户端领取,暂不能修改')
+        }
+        const courses = this.normalizeArray(payload.courses || payload.COURSES)
+        const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
+        const intervalMs = Number(payload.interval_ms || payload.INTERVAL_MS || 500)
+        if (!courses.length && !courseGroups.length) {
+            throw new Error('至少需要填写一门课程或一个课程分组')
+        }
+        const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
+        const params = [
+            payload.name,
+            payload.jx0502zbid || payload.id,
+            payload.student_num || payload.user,
+            JSON.stringify(courses),
+            JSON.stringify(courseGroups),
+            payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
+            intervalMs,
+            TASK_STATUS.PENDING,
+            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 = ?, jx0502zbid = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, 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('更新抢课任务失败')
+        }
+        await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
+        this.logInfo('updateTask', '抢课任务已更新并重新进入待分配队列', { taskId, uuid }, {
+            name: payload.name,
+            student_num: payload.student_num || payload.user,
+            courses_count: courses.length,
+            course_groups_count: courseGroups.length,
+            interval_ms: intervalMs,
+            password_changed: !!(payload.password || payload.pass)
+        })
+    }
+
+    async listUserTasks(uuid) {
+        const rows = await db.query('SELECT * FROM qk_task WHERE create_user = ? ORDER BY create_time DESC', [uuid])
+        return (rows || []).map(row => this.serializeTask(row))
+    }
+
+    async getTaskDetail(uuid, taskId) {
+        const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
+        if (!rows || rows.length === 0) {
+            return null
+        }
+        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]),
+            logs: (logs || []).map(log => {
+                if (typeof log.payload_json === 'string' && log.payload_json) {
+                    try {
+                        log.payload_json = JSON.parse(log.payload_json)
+                    } catch (_) {}
+                }
+                return log
+            })
+        }
+    }
+
+    async cancelTask(uuid, taskId) {
+        const result = await db.query(
+            'UPDATE qk_task SET status = ?, update_time = ?, finished_time = ? WHERE id = ? AND create_user = ? AND status IN (?, ?)',
+            [TASK_STATUS.CANCELLED, Date.now(), Date.now(), taskId, uuid, TASK_STATUS.PENDING, TASK_STATUS.FAILED]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('任务不存在或当前状态不可取消')
+        }
+        await this.logTask(taskId, null, 'cancelled', '用户取消抢课任务')
+        this.logInfo('cancelTask', '用户已取消抢课任务', { taskId, uuid })
+    }
+
+    async getAdminTask(taskId) {
+        const rows = await db.query(
+            `SELECT t.*, u.username, u.avatar
+            FROM qk_task t
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
+            WHERE t.id = ?`,
+            [taskId]
+        )
+        if (!rows || rows.length === 0) {
+            return null
+        }
+        return this.serializeTask(rows[0], true)
+    }
+
+    async decrementClientSlots(clientId, now = Date.now()) {
+        if (!clientId) return
+        await db.query(
+            'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
+            [now, clientId]
+        )
+    }
+
+    async adminUpdateTask(taskId, payload) {
+        const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
+        if (!rows || rows.length === 0) {
+            throw new Error('任务不存在')
+        }
+        const task = rows[0]
+        if (task.status === TASK_STATUS.SUCCESS) {
+            throw new Error('已成功的任务不可编辑')
+        }
+        const courses = this.normalizeArray(payload.courses || payload.COURSES)
+        const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
+        const intervalMs = Number(payload.interval_ms || payload.INTERVAL_MS || task.interval_ms || 500)
+        if (!courses.length && !courseGroups.length) {
+            throw new Error('至少需要填写一门课程或一个课程分组')
+        }
+        if (!Number.isFinite(intervalMs) || intervalMs < 200 || intervalMs > 10000) {
+            throw new Error('抢课间隔需在 200-10000ms 之间')
+        }
+        const now = Date.now()
+        const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
+        if (wasAssigned) {
+            await this.decrementClientSlots(task.assigned_client_id, now)
+        }
+        const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
+        const params = [
+            payload.name,
+            payload.jx0502zbid || payload.id,
+            payload.student_num || payload.user,
+            JSON.stringify(courses),
+            JSON.stringify(courseGroups),
+            payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
+            intervalMs,
+            TASK_STATUS.PENDING,
+            now
+        ]
+        if (passwordSql) {
+            params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
+        }
+        params.push(taskId)
+        const sql = `UPDATE qk_task SET name = ?, jx0502zbid = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, 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('更新抢课任务失败')
+        }
+        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,
+            released_from_client: wasAssigned ? task.assigned_client_id : null
+        })
+    }
+
+    async adminCancelTask(taskId) {
+        const rows = await db.query('SELECT id, status, assigned_client_id FROM qk_task WHERE id = ?', [taskId])
+        if (!rows || rows.length === 0) {
+            throw new Error('任务不存在')
+        }
+        const task = rows[0]
+        if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED].includes(task.status)) {
+            throw new Error('当前状态不可取消')
+        }
+        const now = Date.now()
+        if ([TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
+            await this.decrementClientSlots(task.assigned_client_id, now)
+        }
+        const result = await db.query(
+            `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
+            exclude_client_id = NULL, update_time = ?, finished_time = ? WHERE id = ? AND status IN (?, ?, ?, ?)`,
+            [TASK_STATUS.CANCELLED, now, now, taskId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('取消抢课任务失败')
+        }
+        await this.logTask(taskId, task.assigned_client_id, 'admin_cancelled', '管理员取消抢课任务')
+        this.logInfo('adminCancelTask', '管理员已取消抢课任务', { taskId })
+    }
+
+    async adminRetryTask(taskId) {
+        const rows = await db.query('SELECT id, status FROM qk_task WHERE id = ?', [taskId])
+        if (!rows || rows.length === 0) {
+            throw new Error('任务不存在')
+        }
+        if (![TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
+            throw new Error('仅失败或已取消的任务可重试')
+        }
+        const now = Date.now()
+        const result = await db.query(
+            `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
+            exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL, update_time = ?
+            WHERE id = ? AND status IN (?, ?)`,
+            [TASK_STATUS.PENDING, now, taskId, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('重试抢课任务失败')
+        }
+        await this.logTask(taskId, null, 'admin_retry', '管理员将任务重新加入待分配队列')
+        this.logInfo('adminRetryTask', '管理员已重试抢课任务', { taskId })
+    }
+
+    async authenticateClient(clientId, clientSecret) {
+        if (!clientId || !clientSecret) {
+            this.logWarn('authClient', '客户端认证失败:缺少凭证', { clientId: clientId || 'unknown' })
+            return null
+        }
+        const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
+        if (!rows || rows.length !== 1) {
+            this.logWarn('authClient', '客户端认证失败:客户端不存在或已禁用', { clientId })
+            return null
+        }
+        if (!bcryptjs.compareSync(String(clientSecret), rows[0].client_secret_hash)) {
+            this.logWarn('authClient', '客户端认证失败:密钥不匹配', { clientId })
+            return null
+        }
+        return rows[0]
+    }
+
+    async enrollOrAuthenticateClient(clientId, clientSecret, payload = {}) {
+        const existing = await this.authenticateClient(clientId, clientSecret)
+        if (existing) {
+            return existing
+        }
+        if (!clientId || !clientSecret || !String(clientId).startsWith('qk-cli-')) {
+            throw new Error('客户端凭证无效')
+        }
+        const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ?', [clientId])
+        if (rows && rows.length > 0) {
+            throw new Error('客户端凭证无效')
+        }
+        const time = Date.now()
+        const label = payload.label || payload.hostname || `auto-${clientId}`
+        try {
+            const result = await db.query(
+                'INSERT INTO qk_client (client_id, client_secret_hash, label, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
+                [clientId, bcryptjs.hashSync(String(clientSecret), 10), label, time, time]
+            )
+            if (!result || result.affectedRows <= 0) {
+                throw new Error('客户端自动注册失败')
+            }
+        } catch (err) {
+            if (err?.code === 'ER_DUP_ENTRY') {
+                const raced = await this.authenticateClient(clientId, clientSecret)
+                if (raced) {
+                    return raced
+                }
+            }
+            throw err
+        }
+        const created = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
+        if (!created || created.length !== 1) {
+            throw new Error('客户端自动注册失败')
+        }
+        this.logInfo('enrollClient', '抢课客户端已自动注册', { clientId }, { label })
+        return created[0]
+    }
+
+    async registerClient(clientId, clientSecret, payload = {}) {
+        const client = await this.enrollOrAuthenticateClient(clientId, clientSecret, payload)
+        const maxSlots = this.resolveClientMaxSlots(client, payload)
+        const currentSlots = Math.max(0, Number(payload.current_slots || 0))
+        const time = Date.now()
+        await db.query(
+            'UPDATE qk_client SET label = COALESCE(NULLIF(?, \'\'), label), max_slots = ?, current_slots = ?, hostname = ?, os_username = ?, cpu_model = ?, cpu_threads = ?, total_mem_mb = ?, free_mem_mb = ?, platform = ?, last_heartbeat_at = ?, online = 1, update_time = ? WHERE client_id = ?',
+            [
+                payload.label || '',
+                maxSlots,
+                currentSlots,
+                payload.hostname || null,
+                payload.os_username || null,
+                payload.cpu_model || null,
+                payload.cpu_threads || null,
+                payload.total_mem_mb || null,
+                payload.free_mem_mb || null,
+                payload.platform || null,
+                time,
+                time,
+                clientId
+            ]
+        )
+        await Redis.set(`qk:client:hb:${clientId}`, String(time), { EX: this.heartbeatTtlSeconds })
+        this.logInfo('registerClient', '抢课客户端已注册/上线', { clientId }, {
+            label: payload.label || client.label,
+            max_slots: maxSlots,
+            current_slots: currentSlots,
+            hostname: payload.hostname,
+            platform: payload.platform,
+            cpu_threads: payload.cpu_threads,
+            total_mem_mb: payload.total_mem_mb,
+            free_mem_mb: payload.free_mem_mb
+        })
+        return { client_id: clientId, max_slots: maxSlots, current_slots: currentSlots }
+    }
+
+    async heartbeat(clientId, clientSecret, payload = {}) {
+        const client = await this.authenticateClient(clientId, clientSecret)
+        if (!client) {
+            throw new Error('客户端凭证无效')
+        }
+        const runningTasks = Array.isArray(payload.running_tasks) ? payload.running_tasks.map(Number).filter(Boolean) : []
+        const currentSlots = Math.max(0, Number(payload.current_slots ?? runningTasks.length))
+        const maxSlots = this.resolveClientMaxSlots(client, payload)
+        const now = Date.now()
+        await db.query(
+            'UPDATE qk_client SET max_slots = ?, current_slots = ?, hostname = ?, os_username = ?, cpu_model = ?, cpu_threads = ?, total_mem_mb = ?, free_mem_mb = ?, platform = ?, last_heartbeat_at = ?, online = 1, update_time = ? WHERE client_id = ?',
+            [
+                maxSlots,
+                currentSlots,
+                payload.hostname || null,
+                payload.os_username || null,
+                payload.cpu_model || null,
+                payload.cpu_threads || null,
+                payload.total_mem_mb || null,
+                payload.free_mem_mb || null,
+                payload.platform || null,
+                now,
+                now,
+                clientId
+            ]
+        )
+        await Redis.set(`qk:client:hb:${clientId}`, String(now), { EX: this.heartbeatTtlSeconds })
+        await Redis.set(`qk:client:slots:${clientId}`, String(currentSlots), { EX: this.heartbeatTtlSeconds })
+        if (runningTasks.length > 0) {
+            const leaseExpireAt = now + this.leaseMs
+            const placeholders = runningTasks.map(() => '?').join(',')
+            await db.query(
+                `UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
+                [TASK_STATUS.RUNNING, leaseExpireAt, now, clientId, ...runningTasks, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            this.logInfo('heartbeat', '客户端心跳续租运行中任务', { clientId }, {
+                running_tasks: runningTasks,
+                lease_expire_at: leaseExpireAt,
+                current_slots: currentSlots,
+                max_slots: maxSlots
+            })
+        }
+        await this.releaseOrphanedClientTasks(clientId, runningTasks, now)
+        return { client_id: clientId, current_slots: currentSlots, max_slots: maxSlots }
+    }
+
+    async releaseOrphanedClientTasks(clientId, runningTasks, now = Date.now()) {
+        const assigned = await db.query(
+            'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
+            [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+        )
+        const runningSet = new Set((runningTasks || []).map(Number).filter(Boolean))
+        let released = 0
+        for (const row of assigned || []) {
+            if (runningSet.has(row.id)) continue
+            const result = await db.query(
+                'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
+                [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            if (result && result.affectedRows > 0) {
+                released += 1
+                await this.logTask(row.id, clientId, 'released', '客户端未继续执行,任务已释放回队列')
+                this.logInfo('releaseOrphaned', '释放未在运行的已分配任务', { taskId: row.id, clientId })
+            }
+        }
+        return released
+    }
+
+    async reclaimTasks(clientId, clientSecret) {
+        const client = await this.authenticateClient(clientId, clientSecret)
+        if (!client) {
+            throw new Error('客户端凭证无效')
+        }
+        const now = Date.now()
+        const leaseExpireAt = now + this.leaseMs
+        const rows = await db.query(
+            'SELECT * FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?) ORDER BY create_time ASC',
+            [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+        )
+        if (!rows || rows.length === 0) {
+            return []
+        }
+        for (const row of rows) {
+            await db.query(
+                'UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
+                [TASK_STATUS.RUNNING, leaseExpireAt, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            await this.logTask(row.id, clientId, 'reclaimed', '客户端重连后回收任务继续执行')
+        }
+        this.logInfo('reclaimTasks', `客户端回收 ${rows.length} 个进行中任务`, { clientId }, {
+            task_ids: rows.map(row => row.id),
+            lease_expire_at: leaseExpireAt
+        })
+        return rows.map(row => this.serializeTask({
+            ...row,
+            assigned_client_id: clientId,
+            lease_expire_at: leaseExpireAt,
+            status: TASK_STATUS.RUNNING
+        }, true))
+    }
+
+    async releaseTasks(clientId, clientSecret, payload = {}) {
+        const client = await this.authenticateClient(clientId, clientSecret)
+        if (!client) {
+            throw new Error('客户端凭证无效')
+        }
+        const now = Date.now()
+        const taskIds = Array.isArray(payload.task_ids)
+            ? payload.task_ids.map(Number).filter(Boolean)
+            : []
+        let rows = []
+        if (taskIds.length > 0) {
+            const placeholders = taskIds.map(() => '?').join(',')
+            rows = await db.query(
+                `SELECT id FROM qk_task WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
+                [clientId, ...taskIds, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+        } else {
+            rows = await db.query(
+                'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
+                [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+        }
+        let released = 0
+        for (const row of rows || []) {
+            const result = await db.query(
+                'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
+                [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            if (result && result.affectedRows > 0) {
+                released += 1
+                await this.logTask(row.id, clientId, 'released', '客户端主动释放任务')
+            }
+        }
+        if (released > 0) {
+            this.logInfo('releaseTasks', `客户端释放 ${released} 个任务`, { clientId }, {
+                task_ids: (rows || []).map(row => row.id)
+            })
+        }
+        return { released, task_ids: (rows || []).map(row => row.id) }
+    }
+
+    async pullTasks(clientId, clientSecret, count) {
+        const client = await this.authenticateClient(clientId, clientSecret)
+        if (!client) {
+            throw new Error('客户端凭证无效')
+        }
+        const availableSlots = this.getClientAvailableSlots(client)
+        const safeCount = Math.max(0, Math.min(50, Number(count || 0), availableSlots))
+        if (safeCount <= 0) {
+            this.logWarn('pullTasks', '客户端无可用槽位或拉取数量无效,已忽略', { clientId }, {
+                count,
+                available_slots: availableSlots,
+                max_slots: this.resolveClientMaxSlots(client),
+                current_slots: client.current_slots,
+                free_mem_mb: client.free_mem_mb
+            })
+            return []
+        }
+        const lockKey = `qk:pull:lock:${clientId}`
+        const locked = await Redis.set(lockKey, '1', { NX: true, EX: this.pullLockTtlSeconds })
+        if (!locked) {
+            this.logWarn('pullTasks', '拉取任务被并发锁拦截,本次跳过', { clientId }, { count: safeCount })
+            return []
+        }
+        const conn = await db.connect()
+        try {
+            await conn.beginTransaction()
+            const [rows] = await conn.execute(
+                `SELECT * FROM qk_task
+                WHERE status = ?
+                AND (exclude_client_id IS NULL OR exclude_client_id <> ?)
+                ORDER BY create_time ASC LIMIT ${safeCount} FOR UPDATE`,
+                [TASK_STATUS.PENDING, clientId]
+            )
+            const now = Date.now()
+            const leaseExpireAt = now + this.leaseMs
+            for (const row of rows) {
+                await conn.execute(
+                    'UPDATE qk_task SET status = ?, assigned_client_id = ?, lease_expire_at = ?, assigned_at = ?, exclude_client_id = NULL, update_time = ? WHERE id = ?',
+                    [TASK_STATUS.ASSIGNED, clientId, leaseExpireAt, now, now, row.id]
+                )
+            }
+            await conn.commit()
+            if (rows.length > 0) {
+                await db.query(
+                    'UPDATE qk_client SET current_slots = current_slots + ?, update_time = ? WHERE client_id = ?',
+                    [rows.length, now, clientId]
+                )
+            }
+            for (const row of rows) {
+                await this.logTask(row.id, clientId, 'assigned', '任务已分配给客户端')
+            }
+            if (rows.length > 0) {
+                this.logInfo('pullTasks', `已分配 ${rows.length} 个抢课任务`, { clientId }, {
+                    task_ids: rows.map(row => row.id),
+                    lease_expire_at: leaseExpireAt,
+                    requested_count: safeCount
+                })
+            }
+            return rows.map(row => this.serializeTask({ ...row, assigned_client_id: clientId, lease_expire_at: leaseExpireAt, status: TASK_STATUS.ASSIGNED }, true))
+        } catch (err) {
+            await conn.rollback()
+            this.logError('pullTasks', '拉取并分配任务失败,事务已回滚', { clientId }, err)
+            throw err
+        } finally {
+            await Redis.del(lockKey)
+        }
+    }
+
+    async reportResult(clientId, clientSecret, payload = {}) {
+        const client = await this.authenticateClient(clientId, clientSecret)
+        if (!client) {
+            throw new Error('客户端凭证无效')
+        }
+        const taskId = Number(payload.task_id || payload.id)
+        const success = payload.success === true || payload.status === TASK_STATUS.SUCCESS
+        const status = success ? TASK_STATUS.SUCCESS : TASK_STATUS.FAILED
+        const now = Date.now()
+        const resultJson = payload.result ? JSON.stringify(payload.result) : JSON.stringify({
+            course: payload.course || '',
+            message: payload.message || ''
+        })
+        const result = await db.query(
+            'UPDATE qk_task SET status = ?, result_json = ?, error_msg = ?, update_time = ?, finished_time = ?, lease_expire_at = NULL WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
+            [status, resultJson, success ? null : (payload.error_msg || payload.message || '抢课失败'), now, now, taskId, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+        )
+        if (!result || result.affectedRows <= 0) {
+            this.logWarn('reportResult', '任务结果上报被拒绝:任务不存在或不属于当前客户端', { taskId, clientId }, {
+                success,
+                status
+            })
+            throw new Error('任务不存在或不属于当前客户端')
+        }
+        await db.query(
+            `UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), total_completed = total_completed + 1, total_success = total_success + ?, update_time = ? WHERE client_id = ?`,
+            [success ? 1 : 0, now, clientId]
+        )
+        await this.logTask(taskId, clientId, success ? 'grab_success' : 'grab_fail', payload.message || payload.error_msg || '', payload.result || payload)
+        this.logInfo('reportResult', success ? '抢课成功' : '抢课失败', { taskId, clientId }, {
+            success,
+            message: payload.message || payload.error_msg || '',
+            course: payload.course || payload.result?.course || '',
+            result: payload.result || null
+        })
+        return { task_id: taskId, status }
+    }
+
+    async listClients() {
+        const rows = await db.query('SELECT id, client_id, label, enabled, max_slots, current_slots, hostname, os_username, cpu_model, cpu_threads, total_mem_mb, free_mem_mb, platform, last_heartbeat_at, online, total_completed, total_success, create_time, update_time FROM qk_client ORDER BY update_time DESC')
+        return rows || []
+    }
+
+    async deleteClient(clientId) {
+        const result = await db.query('UPDATE qk_client SET enabled = 0, online = 0, update_time = ? WHERE client_id = ?', [Date.now(), clientId])
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('客户端不存在')
+        }
+        this.logInfo('deleteClient', '抢课客户端已禁用', { clientId })
+    }
+
+    async listAdminTasks(filters = {}) {
+        const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
+        const current = Math.max(1, Number(filters.current || 1))
+        const where = ['1 = 1']
+        const params = []
+        const countParams = []
+        if (filters.status) {
+            where.push('t.status = ?')
+            params.push(filters.status)
+            countParams.push(filters.status)
+        }
+        if (filters.client_id) {
+            where.push('t.assigned_client_id = ?')
+            params.push(filters.client_id)
+            countParams.push(filters.client_id)
+        }
+        if (filters.student_num) {
+            where.push('t.student_num LIKE ?')
+            params.push(`%${filters.student_num}%`)
+            countParams.push(`%${filters.student_num}%`)
+        }
+        if (filters.username) {
+            where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
+            params.push(`%${filters.username}%`)
+            countParams.push(`%${filters.username}%`)
+        }
+        const whereSql = where.join(' AND ')
+        const offset = (current - 1) * pagesize
+        const countRows = await db.query(
+            `SELECT COUNT(*) AS total FROM qk_task t
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}`,
+            countParams
+        )
+        const rows = await db.query(
+            `SELECT t.*, u.username, u.avatar
+            FROM qk_task t
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
+            WHERE ${whereSql}
+            ORDER BY t.create_time DESC
+            LIMIT ${pagesize} OFFSET ${offset}`,
+            params
+        )
+        return {
+            list: (rows || []).map(row => this.serializeTask(row, true)),
+            total: countRows?.[0]?.total || 0,
+            current,
+            pagesize
+        }
+    }
+
+    async reassignStaleRunningTasks() {
+        const onlineCount = await this.countOnlineClients()
+        if (onlineCount <= 1) {
+            return 0
+        }
+
+        const now = Date.now()
+        const staleBefore = now - this.staleTaskMs
+        const rows = await db.query(
+            `SELECT id, assigned_client_id FROM qk_task
+            WHERE status IN (?, ?)
+            AND assigned_at IS NOT NULL
+            AND assigned_at < ?`,
+            [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, staleBefore]
+        )
+
+        let count = 0
+        for (const row of rows || []) {
+            const result = await db.query(
+                `UPDATE qk_task
+                SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
+                    assigned_at = NULL, exclude_client_id = ?, update_time = ?
+                WHERE id = ? AND status IN (?, ?)`,
+                [TASK_STATUS.PENDING, row.assigned_client_id, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            if (result && result.affectedRows > 0) {
+                count += 1
+                if (row.assigned_client_id) {
+                    await db.query(
+                        'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
+                        [now, row.assigned_client_id]
+                    )
+                }
+                await this.logTask(row.id, row.assigned_client_id, 'reassigned', '任务超过1小时未成功,已收回并等待分配给其他客户端')
+                this.logWarn('reassignStale', '长时间未成功任务已收回', {
+                    taskId: row.id,
+                    clientId: row.assigned_client_id
+                }, { online_clients: onlineCount })
+            }
+        }
+        return count
+    }
+
+    async requeueExpiredTasks() {
+        const now = Date.now()
+        const rows = await db.query(
+            'SELECT id, assigned_client_id FROM qk_task WHERE status IN (?, ?) AND lease_expire_at IS NOT NULL AND lease_expire_at < ?',
+            [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, now]
+        )
+        let count = 0
+        for (const row of rows || []) {
+            const result = await db.query(
+                'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND status IN (?, ?)',
+                [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            if (result && result.affectedRows > 0) {
+                count++
+                if (row.assigned_client_id) {
+                    await db.query(
+                        'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
+                        [now, row.assigned_client_id]
+                    )
+                }
+                await this.logTask(row.id, row.assigned_client_id, 'reassigned', '租约过期,任务已重新进入待分配队列')
+                this.logWarn('requeueExpired', '租约过期,任务已重新入队', {
+                    taskId: row.id,
+                    clientId: row.assigned_client_id
+                })
+            }
+        }
+        const offlineResult = await db.query(
+            'UPDATE qk_client SET online = 0, current_slots = 0, update_time = ? WHERE online = 1 AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)',
+            [now, now - this.heartbeatTtlSeconds * 1000]
+        )
+        const offlineCount = offlineResult?.affectedRows || 0
+        const staleCount = await this.reassignStaleRunningTasks()
+        if (count > 0 || offlineCount > 0 || staleCount > 0) {
+            this.logInfo('requeueExpired', '租约巡检完成', {}, {
+                expired_tasks: (rows || []).length,
+                requeued: count,
+                stale_reassigned: staleCount,
+                clients_marked_offline: offlineCount
+            })
+        }
+        return count + staleCount
+    }
+}
+
+module.exports = {
+    TaskScheduler,
+    TASK_STATUS
+}

+ 9 - 0
lib/Server.js

@@ -11,6 +11,8 @@ const { mq: mqName } = require('../plugin/mq/mqPrefix')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
 const OneBotV11 = require('../plugin/OneBot/OneBotV11')
 const OneBotV11 = require('../plugin/OneBot/OneBotV11')
 const AccessControl = require('./AccessControl')
 const AccessControl = require('./AccessControl')
+const LeaseWatcher = require('./QK/LeaseWatcher')
+const { TaskScheduler } = require('./QK/TaskScheduler')
 const {
 const {
     resolveServerRole,
     resolveServerRole,
     shouldServeApi,
     shouldServeApi,
@@ -164,6 +166,13 @@ class SERVER {
             this.logger.error(`OneBot 初始化异常: ${err.message}`)
             this.logger.error(`OneBot 初始化异常: ${err.message}`)
         }
         }
 
 
+        this.qkLeaseWatcher = new LeaseWatcher({
+            logger: this.logger,
+            intervalMs: config.qk?.leaseWatcherIntervalMs || 30 * 1000,
+            scheduler: new TaskScheduler({ logger: this.logger })
+        })
+        this.qkLeaseWatcher.start()
+
         this.listenHttp()
         this.listenHttp()
     }
     }