Browse Source

✨ feat: 增强选课功能

Pchen. 1 month ago
parent
commit
2f10dc36dc
4 changed files with 221 additions and 8 deletions
  1. 20 1
      apis/QK/Admin/Admin.js
  2. 21 1
      apis/QK/Client/Client.js
  3. 9 6
      lib/PermissionCatalog.js
  4. 171 0
      lib/QK/TaskScheduler.js

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

@@ -164,6 +164,24 @@ class RetryTask extends API {
     }
 }
 
+class ListReport extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/QK/Report/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const result = await scheduler.listAdminReports(req.query)
+            return res.json({ ...BaseStdResponse.OK, data: result })
+        } catch (err) {
+            this.logger.error(`获取抢课回报记录失败:${err.stack || err}`)
+            return fail(res, err, '获取抢课回报记录失败')
+        }
+    }
+}
+
 module.exports = {
     ListClient,
     DeleteClient,
@@ -171,5 +189,6 @@ module.exports = {
     GetTask,
     UpdateTask,
     CancelTask,
-    RetryTask
+    RetryTask,
+    ListReport
 }

+ 21 - 1
apis/QK/Client/Client.js

@@ -143,11 +143,31 @@ class ReportResult extends API {
     }
 }
 
+class ReportProgress extends API {
+    constructor() {
+        super()
+        this.setPath('/QK/Client/ReportProgress')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        try {
+            const { clientId, clientSecret } = getClientCredentials(req.body)
+            const result = await scheduler.reportProgress(clientId, clientSecret, req.body)
+            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
+    ReportResult,
+    ReportProgress
 }

+ 9 - 6
lib/PermissionCatalog.js

@@ -10,9 +10,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.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.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: 'page.qk.taskList', name: '抢课助手', category: PermissionCategory.PAGE, scope_type: 'page', page_route_name: 'qkManage.assistant', 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.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.ban', name: '封禁账户', category: PermissionCategory.ACTION, scope_type: 'action', remark: '封禁或解封用户账户' },
@@ -40,9 +41,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: 'service.createOrder', required_codes: ['page.service.createOrder'] },
     { 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: 'page', resource_key: 'qkManage.assistant', required_codes: ['page.qk.taskList'] },
+    { 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.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.ban', required_codes: ['action.user.ban'] },
@@ -84,6 +86,7 @@ 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: '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: '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 /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'] },

+ 171 - 0
lib/QK/TaskScheduler.js

@@ -14,6 +14,13 @@ const TASK_STATUS = {
     CANCELLED: 'cancelled'
 }
 
+const REPORT_LOG_EVENTS = new Set([
+    'request_result',
+    'progress_snapshot',
+    'grab_success',
+    'grab_fail'
+])
+
 class TaskScheduler {
     constructor(options = {}) {
         this.leaseMs = options.leaseMs || config.qk?.leaseMs || 90 * 1000
@@ -194,6 +201,90 @@ class TaskScheduler {
         this.logInfo('taskLog', message || event, { taskId, clientId }, payload ? { event, ...this.sanitizeForLog(payload) } : { event })
     }
 
+    serializeReportLog(row) {
+        const result = { ...row }
+        if (typeof result.payload_json === 'string' && result.payload_json) {
+            try {
+                result.payload_json = JSON.parse(result.payload_json)
+            } catch (_) {}
+        }
+        return result
+    }
+
+    buildReportMessage(payload = {}) {
+        if (payload.message) {
+            return String(payload.message)
+        }
+        if (payload.error_msg) {
+            return String(payload.error_msg)
+        }
+        if (payload.error) {
+            return String(payload.error)
+        }
+        if (payload.success === true) {
+            return payload.label ? `${payload.label} 成功` : '请求成功'
+        }
+        return payload.label ? `${payload.label} 失败` : '请求失败'
+    }
+
+    async assertClientTaskAccess(clientId, 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.assigned_client_id !== clientId) {
+            throw new Error('任务不属于当前客户端')
+        }
+        if (![TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
+            throw new Error('任务当前状态不可上报')
+        }
+        return task
+    }
+
+    async reportProgress(clientId, clientSecret, payload = {}) {
+        const client = await this.authenticateClient(clientId, clientSecret)
+        if (!client) {
+            throw new Error('客户端凭证无效')
+        }
+        const taskId = Number(payload.task_id || payload.id)
+        if (!taskId) {
+            throw new Error('缺少任务 ID')
+        }
+        const event = String(payload.event || 'request_result')
+        if (!REPORT_LOG_EVENTS.has(event) || event === 'grab_success' || event === 'grab_fail') {
+            throw new Error('不支持的上报类型')
+        }
+        const task = await this.assertClientTaskAccess(clientId, taskId)
+        const message = this.buildReportMessage(payload)
+        const now = Date.now()
+        await this.logTask(taskId, clientId, event, message, payload)
+        const updates = ['update_time = ?']
+        const params = [now]
+        if (task.status === TASK_STATUS.ASSIGNED) {
+            updates.push('status = ?')
+            params.push(TASK_STATUS.RUNNING)
+        }
+        if (payload.success !== true && message) {
+            updates.push('error_msg = ?')
+            params.push(message)
+        }
+        params.push(taskId, clientId)
+        await db.query(
+            `UPDATE qk_task SET ${updates.join(', ')} WHERE id = ? AND assigned_client_id = ?`,
+            params
+        )
+        this.logInfo('reportProgress', '客户端上报抢课进度', { taskId, clientId }, {
+            event,
+            success: payload.success === true,
+            message
+        })
+        return { task_id: taskId, event, message }
+    }
+
     async createTask(uuid, payload) {
         const courses = this.normalizeArray(payload.courses || payload.COURSES)
         const courseGroups = this.normalizeArray(payload.course_groups || payload.COURSE_GROUPS)
@@ -846,6 +937,86 @@ class TaskScheduler {
         }
     }
 
+    async listAdminReports(filters = {}) {
+        const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
+        const current = Math.max(1, Number(filters.current || 1))
+        const where = ['l.event IN (?, ?, ?, ?)']
+        const params = ['request_result', 'progress_snapshot', 'grab_success', 'grab_fail']
+        const countParams = ['request_result', 'progress_snapshot', 'grab_success', 'grab_fail']
+        if (filters.task_id) {
+            where.push('l.task_id = ?')
+            params.push(Number(filters.task_id))
+            countParams.push(Number(filters.task_id))
+        }
+        if (filters.client_id) {
+            where.push('l.client_id = ?')
+            params.push(String(filters.client_id))
+            countParams.push(String(filters.client_id))
+        }
+        if (filters.event) {
+            where.push('l.event = ?')
+            params.push(String(filters.event))
+            countParams.push(String(filters.event))
+        }
+        if (filters.start_time) {
+            where.push('l.create_time >= ?')
+            params.push(Number(filters.start_time))
+            countParams.push(Number(filters.start_time))
+        }
+        if (filters.end_time) {
+            where.push('l.create_time <= ?')
+            params.push(Number(filters.end_time))
+            countParams.push(Number(filters.end_time))
+        }
+        if (filters.student_num) {
+            where.push('t.student_num LIKE ?')
+            params.push(`%${filters.student_num}%`)
+            countParams.push(`%${filters.student_num}%`)
+        }
+        if (filters.name || filters.task_name) {
+            where.push('t.name LIKE ?')
+            params.push(`%${filters.name || filters.task_name}%`)
+            countParams.push(`%${filters.name || filters.task_name}%`)
+        }
+        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}%`)
+        }
+        if (filters.client_label) {
+            where.push('c.label LIKE ?')
+            params.push(`%${filters.client_label}%`)
+            countParams.push(`%${filters.client_label}%`)
+        }
+        const whereSql = where.join(' AND ')
+        const offset = (current - 1) * pagesize
+        const joinSql = `
+            FROM qk_task_log l
+            LEFT JOIN qk_task t ON t.id = l.task_id
+            LEFT JOIN qk_client c ON c.client_id = l.client_id
+            LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci`
+        const countRows = await db.query(
+            `SELECT COUNT(*) AS total ${joinSql} WHERE ${whereSql}`,
+            countParams
+        )
+        const rows = await db.query(
+            `SELECT l.id, l.task_id, l.client_id, l.event, l.message, l.payload_json, l.create_time,
+            t.name AS task_name, t.student_num, t.status AS task_status,
+            c.label AS client_label, u.username, u.avatar
+            ${joinSql}
+            WHERE ${whereSql}
+            ORDER BY l.create_time DESC
+            LIMIT ${pagesize} OFFSET ${offset}`,
+            params
+        )
+        return {
+            list: (rows || []).map(row => this.serializeReportLog(row)),
+            total: countRows?.[0]?.total || 0,
+            current,
+            pagesize
+        }
+    }
+
     async reassignStaleRunningTasks() {
         const onlineCount = await this.countOnlineClients()
         if (onlineCount <= 1) {