Browse Source

✨ feat: 优化抢课逻辑

Pchen. 1 month ago
parent
commit
25df459f91
6 changed files with 509 additions and 66 deletions
  1. 49 1
      apis/QK/Admin/Admin.js
  2. 30 0
      apis/QK/Batch/Batch.js
  3. 51 3
      apis/QK/Task/Task.js
  4. 1 0
      lib/AccessControl.js
  5. 41 8
      lib/PermissionCatalog.js
  6. 337 54
      lib/QK/TaskScheduler.js

+ 49 - 1
apis/QK/Admin/Admin.js

@@ -182,6 +182,52 @@ class ListReport extends API {
     }
     }
 }
 }
 
 
+class ListBatch extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Batch/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const list = await scheduler.listBatches()
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课批次列表失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课批次列表失败')
+        }
+    }
+}
+
+class SaveBatch extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Batch/Save')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { id, name, jx0502zbid, enabled } = req.body
+            if (!name || !jx0502zbid) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            if (id) {
+                await scheduler.updateBatch(id, req.body)
+                this.logger.info(`[QK][API][SaveBatch] 更新批次 batchId=${id}`)
+                return res.json({ ...BaseStdResponse.OK, data: { id } })
+            }
+            const batchId = await scheduler.createBatch({ name, jx0502zbid, enabled })
+            this.logger.info(`[QK][API][SaveBatch] 创建批次 batchId=${batchId}`)
+            return res.json({ ...BaseStdResponse.OK, data: { id: batchId } })
+        } catch (err) {
+            this.logger.error(`保存抢课批次失败:${err.stack || err}`)
+            return fail(res, err, '保存抢课批次失败')
+        }
+    }
+}
+
 module.exports = {
 module.exports = {
     ListClient,
     ListClient,
     DeleteClient,
     DeleteClient,
@@ -190,5 +236,7 @@ module.exports = {
     UpdateTask,
     UpdateTask,
     CancelTask,
     CancelTask,
     RetryTask,
     RetryTask,
-    ListReport
+    ListReport,
+    ListBatch,
+    SaveBatch
 }
 }

+ 30 - 0
apis/QK/Batch/Batch.js

@@ -0,0 +1,30 @@
+const API = require('../../../lib/API')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { TaskScheduler } = require('../../../lib/QK/TaskScheduler')
+
+const scheduler = new TaskScheduler()
+
+class ListBatch extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Batch')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const list = await scheduler.listBatches({ enabledOnly: true })
+            return res.json({ ...BaseStdResponse.OK, data: list })
+        } catch (err) {
+            this.logger.error(`获取抢课批次失败:${err.stack || err}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: err?.message || '获取抢课批次失败'
+            })
+        }
+    }
+}
+
+module.exports = {
+    ListBatch
+}

+ 51 - 3
apis/QK/Task/Task.js

@@ -64,8 +64,8 @@ class SaveTask extends API {
 
 
     async onRequest(req, res) {
     async onRequest(req, res) {
         try {
         try {
-            const { uuid, id, name, jx0502zbid, student_num, password } = req.body
-            if ([uuid, name, jx0502zbid, student_num].some(value => value === '' || value === null || value === undefined)) {
+            const { uuid, id, name, batch_id, student_num, password } = req.body
+            if ([uuid, name, batch_id, student_num].some(value => value === '' || value === null || value === undefined)) {
                 return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
                 return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
             }
             }
             if (!id && !password) {
             if (!id && !password) {
@@ -109,9 +109,57 @@ class DeleteTask extends API {
     }
     }
 }
 }
 
 
+class StartTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task/Start')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.startTask(uuid, id)
+            this.logger.info(`[QK][API][StartTask] 用户开启任务 uuid=${uuid} taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`开启抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '开启抢课任务失败')
+        }
+    }
+}
+
+class PauseTask extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Task/Pause')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { uuid, id } = req.body
+            if (!id) {
+                return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+            }
+            await scheduler.pauseTask(uuid, id)
+            this.logger.info(`[QK][API][PauseTask] 用户暂停任务 uuid=${uuid} taskId=${id}`)
+            return res.json({ ...BaseStdResponse.OK })
+        } catch (err) {
+            this.logger.error(`暂停抢课任务失败:${err.stack || err}`)
+            return fail(res, err, '暂停抢课任务失败')
+        }
+    }
+}
+
 module.exports = {
 module.exports = {
     GetTask,
     GetTask,
     GetTaskDetail,
     GetTaskDetail,
     SaveTask,
     SaveTask,
-    DeleteTask
+    DeleteTask,
+    StartTask,
+    PauseTask
 }
 }

+ 1 - 0
lib/AccessControl.js

@@ -57,6 +57,7 @@ class AccessControl {
                 ON DUPLICATE KEY UPDATE
                 ON DUPLICATE KEY UPDATE
                     api_method = VALUES(api_method),
                     api_method = VALUES(api_method),
                     api_path = VALUES(api_path),
                     api_path = VALUES(api_path),
+                    required_codes = VALUES(required_codes),
                     remark = VALUES(remark)`,
                     remark = VALUES(remark)`,
                 [
                 [
                     rule.resource_type,
                     rule.resource_type,

+ 41 - 8
lib/PermissionCatalog.js

@@ -3,6 +3,25 @@ const PermissionCategory = {
     ACTION: 'action'
     ACTION: 'action'
 }
 }
 
 
+/** 抢课助手页面:抢课权限或任一抢课管理页权限均可访问 */
+const QK_ASSISTANT_ACCESS_CODES = [
+    'page.qk.taskList',
+    'page.admin.qk.client',
+    'page.admin.qk.batch',
+    'page.admin.qk.task',
+    'page.admin.qk.report'
+]
+
+const QK_ASSISTANT_SUBMIT_CODES = [
+    'action.qk.task.submit',
+    'action.qk.admin.taskManage'
+]
+
+const QK_ASSISTANT_CONTROL_CODES = [
+    'action.qk.task.control',
+    'action.qk.admin.taskManage'
+]
+
 const DEFAULT_PERMISSION_POINTS = [
 const DEFAULT_PERMISSION_POINTS = [
     { code: 'page.admin.userList', name: '用户管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.userList', remark: '访问后台用户管理页面' },
     { code: 'page.admin.userList', name: '用户管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.userList', remark: '访问后台用户管理页面' },
     { code: 'page.admin.lepaoAccount', name: '乐跑账号管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.lepaoAccount', remark: '访问后台乐跑账号管理页面' },
     { code: 'page.admin.lepaoAccount', name: '乐跑账号管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'admin.lepaoAccount', remark: '访问后台乐跑账号管理页面' },
@@ -10,9 +29,10 @@ 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: 'qkManage.assistant', remark: '管理员访问抢课助手页面' },
+    { code: 'page.qk.taskList', name: '抢课助手', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'lepao.qkAssistant', remark: '授权后可访问校园乐跑下的抢课助手;拥有抢课管理页权限的管理员也可直接访问' },
     { code: 'page.admin.qk.client', name: '抢课客户端管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.client', remark: '访问后台抢课客户端管理页面' },
     { code: 'page.admin.qk.client', name: '抢课客户端管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.client', remark: '访问后台抢课客户端管理页面' },
     { code: 'page.admin.qk.task', name: '抢课任务管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.task', remark: '访问后台抢课任务管理页面' },
     { code: 'page.admin.qk.task', name: '抢课任务管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.task', remark: '访问后台抢课任务管理页面' },
+    { code: 'page.admin.qk.batch', name: '抢课批次管理', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.batch', remark: '访问后台抢课批次管理页面' },
     { code: 'page.admin.qk.report', name: '抢课回报记录', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.report', remark: '查看抢课客户端回报记录' },
     { code: 'page.admin.qk.report', name: '抢课回报记录', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.report', 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: '管理员调整用户乐跑次数' },
@@ -25,8 +45,10 @@ 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.task.submit', name: '提交抢课任务', category: PermissionCategory.ACTION, scope_type: 'action', remark: '提交、更新或取消抢课任务' },
+    { code: 'action.qk.task.control', 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.clientManage', name: '管理抢课客户端', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员禁用抢课客户端' },
+    { code: 'action.qk.admin.batchManage', name: '管理抢课批次', category: PermissionCategory.ACTION, scope_type: 'action', remark: '管理员新建或编辑抢课批次' },
     { code: 'action.qk.admin.taskManage', 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: '管理员审核乐跑次数赠送申请' },
@@ -41,9 +63,10 @@ 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: 'qkManage.assistant', required_codes: ['page.qk.taskList'] },
+    { resource_type: 'page', resource_key: 'lepao.qkAssistant', required_codes: QK_ASSISTANT_ACCESS_CODES },
     { resource_type: 'page', resource_key: 'qkManage.client', required_codes: ['page.admin.qk.client'] },
     { resource_type: 'page', resource_key: 'qkManage.client', required_codes: ['page.admin.qk.client'] },
     { resource_type: 'page', resource_key: 'qkManage.task', required_codes: ['page.admin.qk.task'] },
     { resource_type: 'page', resource_key: 'qkManage.task', required_codes: ['page.admin.qk.task'] },
+    { resource_type: 'page', resource_key: 'qkManage.batch', required_codes: ['page.admin.qk.batch'] },
     { resource_type: 'page', resource_key: 'qkManage.report', required_codes: ['page.admin.qk.report'] },
     { resource_type: 'page', resource_key: 'qkManage.report', required_codes: ['page.admin.qk.report'] },
 
 
     { 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'] },
@@ -57,7 +80,9 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { 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.task.submit', required_codes: ['action.qk.task.submit'] },
+    { resource_type: 'action', resource_key: 'action.qk.task.control', required_codes: ['action.qk.task.control'] },
     { resource_type: 'action', resource_key: 'action.qk.admin.clientManage', required_codes: ['action.qk.admin.clientManage'] },
     { resource_type: 'action', resource_key: 'action.qk.admin.clientManage', required_codes: ['action.qk.admin.clientManage'] },
+    { resource_type: 'action', resource_key: 'action.qk.admin.batchManage', required_codes: ['action.qk.admin.batchManage'] },
     { resource_type: 'action', resource_key: 'action.qk.admin.taskManage', required_codes: ['action.qk.admin.taskManage'] },
     { 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'] },
@@ -75,10 +100,12 @@ 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 /QK/Task', api_method: 'GET', api_path: '/QK/Task', required_codes: QK_ASSISTANT_ACCESS_CODES },
+    { resource_type: 'api', resource_key: 'GET /QK/Task/Detail', api_method: 'GET', api_path: '/QK/Task/Detail', required_codes: QK_ASSISTANT_ACCESS_CODES },
+    { resource_type: 'api', resource_key: 'POST /QK/Task', api_method: 'POST', api_path: '/QK/Task', required_codes: QK_ASSISTANT_SUBMIT_CODES },
+    { resource_type: 'api', resource_key: 'DELETE /QK/Task', api_method: 'DELETE', api_path: '/QK/Task', required_codes: QK_ASSISTANT_SUBMIT_CODES },
+    { resource_type: 'api', resource_key: 'POST /QK/Task/Start', api_method: 'POST', api_path: '/QK/Task/Start', required_codes: QK_ASSISTANT_CONTROL_CODES },
+    { resource_type: 'api', resource_key: 'POST /QK/Task/Pause', api_method: 'POST', api_path: '/QK/Task/Pause', required_codes: QK_ASSISTANT_CONTROL_CODES },
     { 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: '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: '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/List', api_method: 'GET', api_path: '/Admin/QK/Task/List', required_codes: ['page.admin.qk.task'] },
@@ -86,6 +113,9 @@ const DEFAULT_PERMISSION_RESOURCE_RULES = [
     { 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: '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: '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 /Admin/QK/Task/Retry', api_method: 'POST', api_path: '/Admin/QK/Task/Retry', required_codes: ['action.qk.admin.taskManage'] },
+    { resource_type: 'api', resource_key: 'GET /QK/Batch', api_method: 'GET', api_path: '/QK/Batch', required_codes: QK_ASSISTANT_ACCESS_CODES },
+    { resource_type: 'api', resource_key: 'GET /Admin/QK/Batch/List', api_method: 'GET', api_path: '/Admin/QK/Batch/List', required_codes: ['page.admin.qk.batch'] },
+    { resource_type: 'api', resource_key: 'POST /Admin/QK/Batch/Save', api_method: 'POST', api_path: '/Admin/QK/Batch/Save', required_codes: ['action.qk.admin.batchManage'] },
     { resource_type: 'api', resource_key: 'GET /Admin/QK/Report/List', api_method: 'GET', api_path: '/Admin/QK/Report/List', required_codes: ['page.admin.qk.report'] },
     { resource_type: 'api', resource_key: 'GET /Admin/QK/Report/List', api_method: 'GET', api_path: '/Admin/QK/Report/List', required_codes: ['page.admin.qk.report'] },
     { 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'] },
@@ -133,5 +163,8 @@ module.exports = {
     DEFAULT_PERMISSION_POINTS,
     DEFAULT_PERMISSION_POINTS,
     DEFAULT_PERMISSION_RESOURCE_RULES,
     DEFAULT_PERMISSION_RESOURCE_RULES,
     DEFAULT_BASIC_USER_PERMISSION_CODES,
     DEFAULT_BASIC_USER_PERMISSION_CODES,
-    LEGACY_ROLE_PERMISSION_MAP
+    LEGACY_ROLE_PERMISSION_MAP,
+    QK_ASSISTANT_ACCESS_CODES,
+    QK_ASSISTANT_SUBMIT_CODES,
+    QK_ASSISTANT_CONTROL_CODES
 }
 }

+ 337 - 54
lib/QK/TaskScheduler.js

@@ -6,6 +6,7 @@ const Redis = require('../../plugin/DataBase/Redis')
 const Logger = require('../Logger')
 const Logger = require('../Logger')
 
 
 const TASK_STATUS = {
 const TASK_STATUS = {
+    PAUSED: 'paused',
     PENDING: 'pending',
     PENDING: 'pending',
     ASSIGNED: 'assigned',
     ASSIGNED: 'assigned',
     RUNNING: 'running',
     RUNNING: 'running',
@@ -171,6 +172,38 @@ class TaskScheduler {
         return []
         return []
     }
     }
 
 
+    countTaskTargets(courses = [], courseGroups = []) {
+        return this.normalizeArray(courses).length + this.normalizeArray(courseGroups).length
+    }
+
+    getMinTaskInterval(totalCount) {
+        return totalCount >= 5 ? 500 : 200
+    }
+
+    validateTaskCoursesAndInterval(courses, courseGroups, intervalMs) {
+        const normalizedCourses = this.normalizeArray(courses)
+        const normalizedGroups = this.normalizeArray(courseGroups)
+        const totalCount = normalizedCourses.length + normalizedGroups.length
+        if (totalCount <= 0) {
+            throw new Error('至少需要填写一门课程或一个课程分组')
+        }
+        if (totalCount > 10) {
+            throw new Error('每个任务最多选择10门课程或分组')
+        }
+        const minInterval = this.getMinTaskInterval(totalCount)
+        const safeInterval = Number(intervalMs)
+        if (!Number.isFinite(safeInterval) || safeInterval < minInterval || safeInterval > 10000) {
+            throw new Error(`当前共 ${totalCount} 个抢课目标,间隔需在 ${minInterval}-10000ms 之间`)
+        }
+        return {
+            courses: normalizedCourses,
+            courseGroups: normalizedGroups,
+            intervalMs: safeInterval,
+            totalCount,
+            minInterval
+        }
+    }
+
     serializeTask(row, includeSecret = false) {
     serializeTask(row, includeSecret = false) {
         const result = { ...row }
         const result = { ...row }
         result.enable_ggxxk = Number(result.enable_ggxxk) === 1
         result.enable_ggxxk = Number(result.enable_ggxxk) === 1
@@ -181,6 +214,12 @@ class TaskScheduler {
                 result.result_json = JSON.parse(result.result_json)
                 result.result_json = JSON.parse(result.result_json)
             } catch (_) {}
             } catch (_) {}
         }
         }
+        result.jx0502zbid = row.batch_jx0502zbid || row.jx0502zbid || ''
+        result.batch_name = row.batch_name || ''
+        if (row.batch_enabled !== undefined) {
+            result.batch_enabled = Number(row.batch_enabled) === 1
+        }
+        delete result.batch_jx0502zbid
         if (includeSecret) {
         if (includeSecret) {
             result.password = this.decryptPassword(result.password_enc)
             result.password = this.decryptPassword(result.password_enc)
         }
         }
@@ -188,6 +227,168 @@ class TaskScheduler {
         return result
         return result
     }
     }
 
 
+    taskBatchJoinSql(alias = 't') {
+        return `LEFT JOIN qk_batch b ON b.id = ${alias}.batch_id`
+    }
+
+    taskBatchSelectSql(alias = 't') {
+        return `, b.name AS batch_name, b.jx0502zbid AS batch_jx0502zbid, b.enabled AS batch_enabled`
+    }
+
+    async getBatchById(batchId) {
+        const rows = await db.query('SELECT * FROM qk_batch WHERE id = ?', [batchId])
+        if (!rows || rows.length === 0) {
+            throw new Error('抢课批次不存在')
+        }
+        return rows[0]
+    }
+
+    async requireEnabledBatch(batchId) {
+        const batch = await this.getBatchById(batchId)
+        if (!Number(batch.enabled)) {
+            throw new Error('所选抢课批次未启用')
+        }
+        return batch
+    }
+
+    resolveTaskBatchId(payload = {}) {
+        const batchId = Number(payload.batch_id || payload.batchId)
+        if (!batchId) {
+            throw new Error('请选择抢课批次')
+        }
+        return batchId
+    }
+
+    async releaseAssignedTask(task, now = Date.now()) {
+        if (!task || ![TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
+            return false
+        }
+        if (task.assigned_client_id) {
+            await this.decrementClientSlots(task.assigned_client_id, now)
+        }
+        return true
+    }
+
+    async pauseTasksByBatch(batchId, reason = '批次已停用,任务已收回') {
+        const rows = await db.query(
+            'SELECT id, status, assigned_client_id FROM qk_task WHERE batch_id = ? AND status IN (?, ?, ?)',
+            [batchId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+        )
+        const now = Date.now()
+        let count = 0
+        for (const row of rows || []) {
+            await this.releaseAssignedTask(row, 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 = ? WHERE id = ? AND status IN (?, ?, ?)`,
+                [TASK_STATUS.PAUSED, now, row.id, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+            )
+            if (result && result.affectedRows > 0) {
+                count += 1
+                await this.logTask(row.id, row.assigned_client_id, 'batch_paused', reason)
+            }
+        }
+        if (count > 0) {
+            this.logInfo('pauseTasksByBatch', reason, { batchId }, { affected: count })
+        }
+        return count
+    }
+
+    async redispatchTasksByBatch(batchId, reason = '批次 ID 已变更,任务已重新进入分配队列') {
+        const rows = await db.query(
+            'SELECT id, status, assigned_client_id FROM qk_task WHERE batch_id = ? AND status IN (?, ?, ?)',
+            [batchId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+        )
+        const now = Date.now()
+        let count = 0
+        for (const row of rows || []) {
+            if ([TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(row.status)) {
+                await this.releaseAssignedTask(row, 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 = ? WHERE id = ? AND status IN (?, ?)`,
+                    [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+                )
+                if (result && result.affectedRows > 0) {
+                    count += 1
+                    await this.logTask(row.id, row.assigned_client_id, 'batch_redispatch', reason)
+                }
+            }
+        }
+        if (count > 0) {
+            this.logInfo('redispatchTasksByBatch', reason, { batchId }, { affected: count })
+        }
+        return count
+    }
+
+    serializeBatch(row) {
+        return {
+            id: row.id,
+            name: row.name,
+            jx0502zbid: row.jx0502zbid,
+            enabled: Number(row.enabled) === 1,
+            create_time: row.create_time,
+            update_time: row.update_time
+        }
+    }
+
+    async listBatches(options = {}) {
+        const enabledOnly = !!options.enabledOnly
+        const where = enabledOnly ? 'WHERE enabled = 1' : ''
+        const rows = await db.query(`SELECT * FROM qk_batch ${where} ORDER BY update_time DESC, id DESC`)
+        return (rows || []).map(row => this.serializeBatch(row))
+    }
+
+    async createBatch(payload = {}) {
+        const name = String(payload.name || '').trim()
+        const jx0502zbid = String(payload.jx0502zbid || '').trim()
+        if (!name || !jx0502zbid) {
+            throw new Error('批次名称和批次 ID 不能为空')
+        }
+        const now = Date.now()
+        const result = await db.query(
+            'INSERT INTO qk_batch (name, jx0502zbid, enabled, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
+            [name, jx0502zbid, payload.enabled === false ? 0 : 1, now, now]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('创建抢课批次失败')
+        }
+        this.logInfo('createBatch', '抢课批次已创建', { batchId: result.insertId }, { name, jx0502zbid })
+        return result.insertId
+    }
+
+    async updateBatch(batchId, payload = {}) {
+        const existing = await this.getBatchById(batchId)
+        const name = payload.name !== undefined ? String(payload.name || '').trim() : existing.name
+        const jx0502zbid = payload.jx0502zbid !== undefined ? String(payload.jx0502zbid || '').trim() : existing.jx0502zbid
+        const enabled = payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : Number(existing.enabled)
+        if (!name || !jx0502zbid) {
+            throw new Error('批次名称和批次 ID 不能为空')
+        }
+        const now = Date.now()
+        const result = await db.query(
+            'UPDATE qk_batch SET name = ?, jx0502zbid = ?, enabled = ?, update_time = ? WHERE id = ?',
+            [name, jx0502zbid, enabled, now, batchId]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('更新抢课批次失败')
+        }
+        const disabledNow = Number(existing.enabled) === 1 && enabled === 0
+        const idChanged = String(existing.jx0502zbid) !== jx0502zbid
+        if (disabledNow) {
+            await this.pauseTasksByBatch(batchId)
+        } else if (idChanged && enabled === 1) {
+            await this.redispatchTasksByBatch(batchId)
+        }
+        this.logInfo('updateBatch', '抢课批次已更新', { batchId }, {
+            name,
+            jx0502zbid,
+            enabled: enabled === 1,
+            disabledNow,
+            idChanged
+        })
+    }
+
     async logTask(taskId, clientId, event, message = '', payload = null) {
     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 (?, ?, ?, ?, ?, ?)'
         const sql = 'INSERT INTO qk_task_log (task_id, client_id, event, message, payload_json, create_time) VALUES (?, ?, ?, ?, ?, ?)'
         await db.query(sql, [
         await db.query(sql, [
@@ -286,41 +487,41 @@ class TaskScheduler {
     }
     }
 
 
     async createTask(uuid, payload) {
     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 validated = this.validateTaskCoursesAndInterval(
+            payload.courses || payload.COURSES,
+            payload.course_groups || payload.COURSE_GROUPS,
+            payload.interval_ms || payload.INTERVAL_MS || 500
+        )
+        const { courses, courseGroups, intervalMs } = validated
+        const batchId = this.resolveTaskBatchId(payload)
+        await this.requireEnabledBatch(batchId)
         const time = Date.now()
         const time = Date.now()
         const sql = `INSERT INTO qk_task
         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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+            (create_user, name, batch_id, jx0502zbid, student_num, password_enc, courses, course_groups, enable_ggxxk, interval_ms, status, create_time, update_time)
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
         const result = await db.query(sql, [
         const result = await db.query(sql, [
             uuid,
             uuid,
             payload.name,
             payload.name,
-            payload.jx0502zbid || payload.id,
+            batchId,
+            '',
             payload.student_num || payload.user,
             payload.student_num || payload.user,
             this.encryptPassword(payload.password || payload.pass),
             this.encryptPassword(payload.password || payload.pass),
             JSON.stringify(courses),
             JSON.stringify(courses),
             JSON.stringify(courseGroups),
             JSON.stringify(courseGroups),
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             intervalMs,
             intervalMs,
-            TASK_STATUS.PENDING,
+            TASK_STATUS.PAUSED,
             time,
             time,
             time
             time
         ])
         ])
         if (!result || result.affectedRows <= 0) {
         if (!result || result.affectedRows <= 0) {
             throw new Error('创建抢课任务失败')
             throw new Error('创建抢课任务失败')
         }
         }
-        await this.logTask(result.insertId, null, 'created', '用户提交抢课任务')
+        await this.logTask(result.insertId, null, 'created', '用户提交抢课任务(未开始)')
         this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
         this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
             name: payload.name,
             name: payload.name,
+            batch_id: batchId,
             student_num: payload.student_num || payload.user,
             student_num: payload.student_num || payload.user,
-            jx0502zbid: payload.jx0502zbid || payload.id,
             courses_count: courses.length,
             courses_count: courses.length,
             course_groups_count: courseGroups.length,
             course_groups_count: courseGroups.length,
             interval_ms: intervalMs,
             interval_ms: intervalMs,
@@ -334,38 +535,40 @@ class TaskScheduler {
         if (!rows || rows.length === 0) {
         if (!rows || rows.length === 0) {
             throw new Error('任务不存在')
             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('至少需要填写一门课程或一个课程分组')
+        if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
+            throw new Error('任务进行中或已分配,请先暂停后再修改')
         }
         }
+        const validated = this.validateTaskCoursesAndInterval(
+            payload.courses || payload.COURSES,
+            payload.course_groups || payload.COURSE_GROUPS,
+            payload.interval_ms || payload.INTERVAL_MS || 500
+        )
+        const { courses, courseGroups, intervalMs } = validated
+        const batchId = this.resolveTaskBatchId(payload)
+        await this.requireEnabledBatch(batchId)
         const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
         const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
         const params = [
         const params = [
             payload.name,
             payload.name,
-            payload.jx0502zbid || payload.id,
+            batchId,
             payload.student_num || payload.user,
             payload.student_num || payload.user,
             JSON.stringify(courses),
             JSON.stringify(courses),
             JSON.stringify(courseGroups),
             JSON.stringify(courseGroups),
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             intervalMs,
             intervalMs,
-            TASK_STATUS.PENDING,
+            TASK_STATUS.PAUSED,
             Date.now()
             Date.now()
         ]
         ]
         if (passwordSql) {
         if (passwordSql) {
             params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
             params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
         }
         }
         params.push(taskId, uuid)
         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 sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ? AND create_user = ?`
         const result = await db.query(sql, params)
         const result = await db.query(sql, params)
         if (!result || result.affectedRows <= 0) {
         if (!result || result.affectedRows <= 0) {
             throw new Error('更新抢课任务失败')
             throw new Error('更新抢课任务失败')
         }
         }
         await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
         await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
-        this.logInfo('updateTask', '抢课任务已更新并重新进入待分配队列', { taskId, uuid }, {
+        this.logInfo('updateTask', '抢课任务已更新(未开始)', { taskId, uuid }, {
             name: payload.name,
             name: payload.name,
             student_num: payload.student_num || payload.user,
             student_num: payload.student_num || payload.user,
             courses_count: courses.length,
             courses_count: courses.length,
@@ -376,12 +579,25 @@ class TaskScheduler {
     }
     }
 
 
     async listUserTasks(uuid) {
     async listUserTasks(uuid) {
-        const rows = await db.query('SELECT * FROM qk_task WHERE create_user = ? ORDER BY create_time DESC', [uuid])
+        const rows = await db.query(
+            `SELECT t.*${this.taskBatchSelectSql('t')}
+            FROM qk_task t
+            ${this.taskBatchJoinSql('t')}
+            WHERE t.create_user = ?
+            ORDER BY t.create_time DESC`,
+            [uuid]
+        )
         return (rows || []).map(row => this.serializeTask(row))
         return (rows || []).map(row => this.serializeTask(row))
     }
     }
 
 
     async getTaskDetail(uuid, taskId) {
     async getTaskDetail(uuid, taskId) {
-        const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
+        const rows = await db.query(
+            `SELECT t.*${this.taskBatchSelectSql('t')}
+            FROM qk_task t
+            ${this.taskBatchJoinSql('t')}
+            WHERE t.id = ? AND t.create_user = ?`,
+            [taskId, uuid]
+        )
         if (!rows || rows.length === 0) {
         if (!rows || rows.length === 0) {
             return null
             return null
         }
         }
@@ -401,8 +617,8 @@ class TaskScheduler {
 
 
     async cancelTask(uuid, taskId) {
     async cancelTask(uuid, taskId) {
         const result = await db.query(
         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]
+            '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.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.FAILED]
         )
         )
         if (!result || result.affectedRows <= 0) {
         if (!result || result.affectedRows <= 0) {
             throw new Error('任务不存在或当前状态不可取消')
             throw new Error('任务不存在或当前状态不可取消')
@@ -411,11 +627,67 @@ class TaskScheduler {
         this.logInfo('cancelTask', '用户已取消抢课任务', { taskId, uuid })
         this.logInfo('cancelTask', '用户已取消抢课任务', { taskId, uuid })
     }
     }
 
 
+    async startTask(uuid, taskId) {
+        const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
+        if (!rows || rows.length === 0) {
+            throw new Error('任务不存在')
+        }
+        const task = rows[0]
+        if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED].includes(task.status)) {
+            throw new Error('仅未开始或失败的任务可开启')
+        }
+        if (task.batch_id) {
+            await this.requireEnabledBatch(task.batch_id)
+        }
+        const now = Date.now()
+        const result = await db.query(
+            `UPDATE qk_task SET 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 = ? AND create_user = ? AND status IN (?, ?)`,
+            [TASK_STATUS.PENDING, now, taskId, uuid, TASK_STATUS.PAUSED, TASK_STATUS.FAILED]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('开启抢课任务失败')
+        }
+        await this.logTask(taskId, null, 'started', '用户开启抢课任务,等待分配')
+        this.logInfo('startTask', '用户已开启抢课任务', { taskId, uuid })
+    }
+
+    async pauseTask(uuid, taskId) {
+        const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
+        if (!rows || rows.length === 0) {
+            throw new Error('任务不存在')
+        }
+        const task = rows[0]
+        if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
+            throw new Error('当前状态不可暂停')
+        }
+        const now = Date.now()
+        const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
+        if (wasAssigned && task.assigned_client_id) {
+            await this.decrementClientSlots(task.assigned_client_id, now)
+        }
+        const result = await db.query(
+            `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
+            assigned_at = NULL, exclude_client_id = NULL
+            WHERE id = ? AND create_user = ? AND status IN (?, ?, ?)`,
+            [TASK_STATUS.PAUSED, now, taskId, uuid, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
+        )
+        if (!result || result.affectedRows <= 0) {
+            throw new Error('暂停抢课任务失败')
+        }
+        await this.logTask(taskId, task.assigned_client_id, 'paused', wasAssigned ? '用户暂停任务,已收回客户端' : '用户暂停抢课任务')
+        this.logInfo('pauseTask', '用户已暂停抢课任务', { taskId, uuid }, {
+            released_from_client: wasAssigned ? task.assigned_client_id : null
+        })
+    }
+
     async getAdminTask(taskId) {
     async getAdminTask(taskId) {
         const rows = await db.query(
         const rows = await db.query(
-            `SELECT t.*, u.username, u.avatar
+            `SELECT t.*, u.username, u.avatar${this.taskBatchSelectSql('t')}
             FROM qk_task t
             FROM qk_task t
             LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
             LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
+            ${this.taskBatchJoinSql('t')}
             WHERE t.id = ?`,
             WHERE t.id = ?`,
             [taskId]
             [taskId]
         )
         )
@@ -442,42 +714,40 @@ class TaskScheduler {
         if (task.status === TASK_STATUS.SUCCESS) {
         if (task.status === TASK_STATUS.SUCCESS) {
             throw new Error('已成功的任务不可编辑')
             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 validated = this.validateTaskCoursesAndInterval(
+            payload.courses || payload.COURSES,
+            payload.course_groups || payload.COURSE_GROUPS,
+            payload.interval_ms || payload.INTERVAL_MS || task.interval_ms || 500
+        )
+        const { courses, courseGroups, intervalMs } = validated
         const now = Date.now()
         const now = Date.now()
         const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
         const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
         if (wasAssigned) {
         if (wasAssigned) {
             await this.decrementClientSlots(task.assigned_client_id, now)
             await this.decrementClientSlots(task.assigned_client_id, now)
         }
         }
+        const batchId = this.resolveTaskBatchId(payload)
         const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
         const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
         const params = [
         const params = [
             payload.name,
             payload.name,
-            payload.jx0502zbid || payload.id,
+            batchId,
             payload.student_num || payload.user,
             payload.student_num || payload.user,
             JSON.stringify(courses),
             JSON.stringify(courses),
             JSON.stringify(courseGroups),
             JSON.stringify(courseGroups),
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
             intervalMs,
             intervalMs,
-            TASK_STATUS.PENDING,
+            TASK_STATUS.PAUSED,
             now
             now
         ]
         ]
         if (passwordSql) {
         if (passwordSql) {
             params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
             params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
         }
         }
         params.push(taskId)
         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 sql = `UPDATE qk_task SET name = ?, batch_id = ?, student_num = ?${passwordSql}, courses = ?, course_groups = ?, enable_ggxxk = ?, interval_ms = ?, status = ?, jx0502zbid = '', update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL WHERE id = ?`
         const result = await db.query(sql, params)
         const result = await db.query(sql, params)
         if (!result || result.affectedRows <= 0) {
         if (!result || result.affectedRows <= 0) {
             throw new Error('更新抢课任务失败')
             throw new Error('更新抢课任务失败')
         }
         }
-        await this.logTask(taskId, task.assigned_client_id, 'admin_updated', wasAssigned ? '管理员更新任务并收回重新排队' : '管理员更新抢课任务')
+        await this.logTask(taskId, task.assigned_client_id, 'admin_updated', wasAssigned ? '管理员更新任务并收回(未开始)' : '管理员更新抢课任务')
         this.logInfo('adminUpdateTask', '管理员已更新抢课任务', { taskId }, {
         this.logInfo('adminUpdateTask', '管理员已更新抢课任务', { taskId }, {
             name: payload.name,
             name: payload.name,
             student_num: payload.student_num || payload.user,
             student_num: payload.student_num || payload.user,
@@ -491,7 +761,7 @@ class TaskScheduler {
             throw new Error('任务不存在')
             throw new Error('任务不存在')
         }
         }
         const task = rows[0]
         const task = rows[0]
-        if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED].includes(task.status)) {
+        if (![TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED].includes(task.status)) {
             throw new Error('当前状态不可取消')
             throw new Error('当前状态不可取消')
         }
         }
         const now = Date.now()
         const now = Date.now()
@@ -500,8 +770,8 @@ class TaskScheduler {
         }
         }
         const result = await db.query(
         const result = await db.query(
             `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
             `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]
+            exclude_client_id = NULL, update_time = ?, finished_time = ? WHERE id = ? AND status IN (?, ?, ?, ?, ?)`,
+            [TASK_STATUS.CANCELLED, now, now, taskId, TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED]
         )
         )
         if (!result || result.affectedRows <= 0) {
         if (!result || result.affectedRows <= 0) {
             throw new Error('取消抢课任务失败')
             throw new Error('取消抢课任务失败')
@@ -701,7 +971,11 @@ class TaskScheduler {
         const now = Date.now()
         const now = Date.now()
         const leaseExpireAt = now + this.leaseMs
         const leaseExpireAt = now + this.leaseMs
         const rows = await db.query(
         const rows = await db.query(
-            'SELECT * FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?) ORDER BY create_time ASC',
+            `SELECT t.*, b.jx0502zbid AS batch_jx0502zbid, b.name AS batch_name, b.enabled AS batch_enabled
+            FROM qk_task t
+            LEFT JOIN qk_batch b ON b.id = t.batch_id
+            WHERE t.assigned_client_id = ? AND t.status IN (?, ?)
+            ORDER BY t.create_time ASC`,
             [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
             [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
         )
         )
         if (!rows || rows.length === 0) {
         if (!rows || rows.length === 0) {
@@ -794,10 +1068,13 @@ class TaskScheduler {
         try {
         try {
             await conn.beginTransaction()
             await conn.beginTransaction()
             const [rows] = await conn.execute(
             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`,
+                `SELECT t.*, b.jx0502zbid AS batch_jx0502zbid, b.name AS batch_name, b.enabled AS batch_enabled
+                FROM qk_task t
+                LEFT JOIN qk_batch b ON b.id = t.batch_id
+                WHERE t.status = ?
+                AND (t.batch_id IS NULL OR b.enabled = 1)
+                AND (t.exclude_client_id IS NULL OR t.exclude_client_id <> ?)
+                ORDER BY t.create_time ASC LIMIT ${safeCount} FOR UPDATE`,
                 [TASK_STATUS.PENDING, clientId]
                 [TASK_STATUS.PENDING, clientId]
             )
             )
             const now = Date.now()
             const now = Date.now()
@@ -912,6 +1189,11 @@ class TaskScheduler {
             params.push(`%${filters.username}%`)
             params.push(`%${filters.username}%`)
             countParams.push(`%${filters.username}%`)
             countParams.push(`%${filters.username}%`)
         }
         }
+        if (filters.batch_id) {
+            where.push('t.batch_id = ?')
+            params.push(Number(filters.batch_id))
+            countParams.push(Number(filters.batch_id))
+        }
         const whereSql = where.join(' AND ')
         const whereSql = where.join(' AND ')
         const offset = (current - 1) * pagesize
         const offset = (current - 1) * pagesize
         const countRows = await db.query(
         const countRows = await db.query(
@@ -921,9 +1203,10 @@ class TaskScheduler {
             countParams
             countParams
         )
         )
         const rows = await db.query(
         const rows = await db.query(
-            `SELECT t.*, u.username, u.avatar
+            `SELECT t.*, u.username, u.avatar${this.taskBatchSelectSql('t')}
             FROM qk_task t
             FROM qk_task t
             LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
             LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
+            ${this.taskBatchJoinSql('t')}
             WHERE ${whereSql}
             WHERE ${whereSql}
             ORDER BY t.create_time DESC
             ORDER BY t.create_time DESC
             LIMIT ${pagesize} OFFSET ${offset}`,
             LIMIT ${pagesize} OFFSET ${offset}`,