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 }