| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111 |
- 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'
- }
- 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
- 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 })
- }
- 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)
- 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 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) {
- 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
- }
|