TaskScheduler.js 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498
  1. const crypto = require('crypto')
  2. const bcryptjs = require('bcryptjs')
  3. const config = require('../../config.json')
  4. const db = require('../../plugin/DataBase/db')
  5. const Redis = require('../../plugin/DataBase/Redis')
  6. const Logger = require('../Logger')
  7. const TASK_STATUS = {
  8. PAUSED: 'paused',
  9. PENDING: 'pending',
  10. ASSIGNED: 'assigned',
  11. RUNNING: 'running',
  12. SUCCESS: 'success',
  13. FAILED: 'failed',
  14. CANCELLED: 'cancelled'
  15. }
  16. const REPORT_LOG_EVENTS = new Set([
  17. 'request_result',
  18. 'progress_snapshot',
  19. 'grab_success',
  20. 'grab_fail'
  21. ])
  22. class TaskScheduler {
  23. constructor(options = {}) {
  24. this.leaseMs = options.leaseMs || config.qk?.leaseMs || 90 * 1000
  25. this.heartbeatTtlSeconds = options.heartbeatTtlSeconds || config.qk?.heartbeatTtlSeconds || 45
  26. this.pullLockTtlSeconds = options.pullLockTtlSeconds || 5
  27. this.staleTaskMs = options.staleTaskMs || config.qk?.staleTaskMs || 60 * 60 * 1000
  28. this.memPerSlotMb = options.memPerSlotMb || config.qk?.memPerSlotMb || 3072
  29. this.memReserveMb = options.memReserveMb || config.qk?.memReserveMb || 1024
  30. this.maxSlotsCap = options.maxSlotsCap || config.qk?.maxSlotsCap || 10
  31. this.logger = options.logger || new Logger()
  32. }
  33. calculateMaxSlots(profile = {}) {
  34. const freeMb = Math.max(0, Number(profile.free_mem_mb) || 0)
  35. const totalMb = Math.max(0, Number(profile.total_mem_mb) || 0)
  36. const threads = Math.max(1, Number(profile.cpu_threads) || 1)
  37. const allocatableFreeMb = Math.max(0, freeMb - this.memReserveMb)
  38. const allocatableTotalMb = Math.max(0, totalMb - this.memReserveMb)
  39. const byFreeMem = Math.floor(allocatableFreeMb / this.memPerSlotMb)
  40. const byTotalMem = Math.floor(allocatableTotalMb / this.memPerSlotMb)
  41. const byCpu = Math.floor(threads * 0.8)
  42. const fallbackMem = totalMb > 0 ? byTotalMem : 1
  43. const byMem = freeMb > 0 ? Math.min(byFreeMem, byTotalMem) : fallbackMem
  44. return Math.max(1, Math.min(50, this.maxSlotsCap, byMem, byCpu))
  45. }
  46. resolveClientMaxSlots(client, payload = {}) {
  47. const fromPayload = this.calculateMaxSlots({
  48. free_mem_mb: payload.free_mem_mb ?? client.free_mem_mb,
  49. total_mem_mb: payload.total_mem_mb ?? client.total_mem_mb,
  50. cpu_threads: payload.cpu_threads ?? client.cpu_threads
  51. })
  52. const reported = Number(payload.max_slots || client.max_slots || 0)
  53. if (!reported) return fromPayload
  54. return Math.max(1, Math.min(reported, fromPayload))
  55. }
  56. getClientAvailableSlots(client, payload = {}) {
  57. const maxSlots = this.resolveClientMaxSlots(client, payload)
  58. const currentSlots = Math.max(0, Number(payload.current_slots ?? client.current_slots ?? 0))
  59. return Math.max(0, maxSlots - currentSlots)
  60. }
  61. getInFlightTaskStatuses() {
  62. return [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  63. }
  64. /** 同一学号在单次拉取中只保留最早创建的一条待分配任务 */
  65. pickPullableTasks(rows, limit) {
  66. const picked = []
  67. const seenStudentNums = new Set()
  68. for (const row of rows || []) {
  69. const studentNum = String(row.student_num || '').trim()
  70. if (!studentNum || seenStudentNums.has(studentNum)) {
  71. continue
  72. }
  73. seenStudentNums.add(studentNum)
  74. picked.push(row)
  75. if (picked.length >= limit) {
  76. break
  77. }
  78. }
  79. return picked
  80. }
  81. async countOnlineClients() {
  82. const now = Date.now()
  83. const threshold = now - this.heartbeatTtlSeconds * 1000
  84. const rows = await db.query(
  85. `SELECT COUNT(*) AS total FROM qk_client
  86. WHERE enabled = 1 AND online = 1
  87. AND last_heartbeat_at IS NOT NULL AND last_heartbeat_at >= ?`,
  88. [threshold]
  89. )
  90. return Number(rows?.[0]?.total || 0)
  91. }
  92. safeStringify(obj) {
  93. const seen = new WeakSet()
  94. return JSON.stringify(obj, (key, value) => {
  95. if (typeof value === 'object' && value !== null) {
  96. if (seen.has(value)) return '[Circular]'
  97. seen.add(value)
  98. }
  99. return value
  100. })
  101. }
  102. sanitizeForLog(payload) {
  103. if (!payload || typeof payload !== 'object') {
  104. return payload
  105. }
  106. const copy = Array.isArray(payload) ? [...payload] : { ...payload }
  107. for (const key of ['password', 'pass', 'password_enc', 'client_secret']) {
  108. if (key in copy) {
  109. copy[key] = '***'
  110. }
  111. }
  112. return copy
  113. }
  114. buildLogPrefix(tag, ctx = {}) {
  115. const parts = ['[QK]', `[${tag}]`]
  116. if (ctx.taskId) parts.push(`[taskId=${ctx.taskId}]`)
  117. if (ctx.clientId) parts.push(`[clientId=${ctx.clientId}]`)
  118. if (ctx.uuid) parts.push(`[uuid=${ctx.uuid}]`)
  119. return parts.join('')
  120. }
  121. logInfo(tag, message, ctx = {}, data = null) {
  122. const prefix = this.buildLogPrefix(tag, ctx)
  123. const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
  124. this.logger.info(`${prefix} ${message}${suffix}`)
  125. }
  126. logWarn(tag, message, ctx = {}, data = null) {
  127. const prefix = this.buildLogPrefix(tag, ctx)
  128. const suffix = data != null ? ` ${this.safeStringify(this.sanitizeForLog(data))}` : ''
  129. this.logger.warn(`${prefix} ${message}${suffix}`)
  130. }
  131. logError(tag, message, ctx = {}, err = null) {
  132. const prefix = this.buildLogPrefix(tag, ctx)
  133. const suffix = err ? ` ${err.stack || err}` : ''
  134. this.logger.error(`${prefix} ${message}${suffix}`)
  135. }
  136. getPasswordKey() {
  137. const source = process.env.QK_PASSWORD_KEY || config.qk?.passwordAesKey || config.database?.password || 'runforge-qk-default-key'
  138. return crypto.createHash('sha256').update(String(source)).digest()
  139. }
  140. encryptPassword(password) {
  141. const iv = crypto.randomBytes(16)
  142. const cipher = crypto.createCipheriv('aes-256-cbc', this.getPasswordKey(), iv)
  143. let encrypted = cipher.update(String(password), 'utf8', 'base64')
  144. encrypted += cipher.final('base64')
  145. return `${iv.toString('base64')}:${encrypted}`
  146. }
  147. decryptPassword(encrypted) {
  148. const [ivText, payload] = String(encrypted || '').split(':')
  149. if (!ivText || !payload) {
  150. return ''
  151. }
  152. const decipher = crypto.createDecipheriv('aes-256-cbc', this.getPasswordKey(), Buffer.from(ivText, 'base64'))
  153. let decrypted = decipher.update(payload, 'base64', 'utf8')
  154. decrypted += decipher.final('utf8')
  155. return decrypted
  156. }
  157. normalizeArray(value) {
  158. if (Array.isArray(value)) {
  159. return value.map(item => String(item).trim()).filter(Boolean)
  160. }
  161. if (typeof value === 'string') {
  162. const trimmed = value.trim()
  163. if (!trimmed) {
  164. return []
  165. }
  166. try {
  167. const parsed = JSON.parse(trimmed)
  168. if (Array.isArray(parsed)) {
  169. return parsed.map(item => String(item).trim()).filter(Boolean)
  170. }
  171. } catch (_) {
  172. return trimmed.split(/[\n,,]/).map(item => item.trim()).filter(Boolean)
  173. }
  174. }
  175. return []
  176. }
  177. countTaskTargets(courses = [], courseGroups = []) {
  178. return this.normalizeArray(courses).length + this.normalizeArray(courseGroups).length
  179. }
  180. getMinTaskInterval(totalCount) {
  181. return totalCount >= 5 ? 500 : 200
  182. }
  183. validateTaskCoursesAndInterval(courses, courseGroups, intervalMs) {
  184. const normalizedCourses = this.normalizeArray(courses)
  185. const normalizedGroups = this.normalizeArray(courseGroups)
  186. const totalCount = normalizedCourses.length + normalizedGroups.length
  187. if (totalCount <= 0) {
  188. throw new Error('至少需要填写一门课程或一个课程分组')
  189. }
  190. if (totalCount > 10) {
  191. throw new Error('每个任务最多选择10门课程或分组')
  192. }
  193. const minInterval = this.getMinTaskInterval(totalCount)
  194. const safeInterval = Number(intervalMs)
  195. if (!Number.isFinite(safeInterval) || safeInterval < minInterval || safeInterval > 10000) {
  196. throw new Error(`当前共 ${totalCount} 个抢课目标,间隔需在 ${minInterval}-10000ms 之间`)
  197. }
  198. return {
  199. courses: normalizedCourses,
  200. courseGroups: normalizedGroups,
  201. intervalMs: safeInterval,
  202. totalCount,
  203. minInterval
  204. }
  205. }
  206. serializeTask(row, includeSecret = false) {
  207. const result = { ...row }
  208. result.enable_ggxxk = Number(result.enable_ggxxk) === 1
  209. result.courses = this.normalizeArray(result.courses)
  210. result.course_groups = this.normalizeArray(result.course_groups)
  211. if (typeof result.result_json === 'string' && result.result_json) {
  212. try {
  213. result.result_json = JSON.parse(result.result_json)
  214. } catch (_) {}
  215. }
  216. result.jx0502zbid = row.batch_jx0502zbid || row.jx0502zbid || ''
  217. result.batch_name = row.batch_name || ''
  218. if (row.batch_enabled !== undefined) {
  219. result.batch_enabled = Number(row.batch_enabled) === 1
  220. }
  221. delete result.batch_jx0502zbid
  222. if (includeSecret) {
  223. result.password = this.decryptPassword(result.password_enc)
  224. }
  225. delete result.password_enc
  226. return result
  227. }
  228. taskBatchJoinSql(alias = 't') {
  229. return `LEFT JOIN qk_batch b ON b.id = ${alias}.batch_id`
  230. }
  231. taskBatchSelectSql(alias = 't') {
  232. return `, b.name AS batch_name, b.jx0502zbid AS batch_jx0502zbid, b.enabled AS batch_enabled`
  233. }
  234. async getBatchById(batchId) {
  235. const rows = await db.query('SELECT * FROM qk_batch WHERE id = ?', [batchId])
  236. if (!rows || rows.length === 0) {
  237. throw new Error('抢课批次不存在')
  238. }
  239. return rows[0]
  240. }
  241. async requireEnabledBatch(batchId) {
  242. const batch = await this.getBatchById(batchId)
  243. if (!Number(batch.enabled)) {
  244. throw new Error('所选抢课批次未启用')
  245. }
  246. return batch
  247. }
  248. resolveTaskBatchId(payload = {}) {
  249. const batchId = Number(payload.batch_id || payload.batchId)
  250. if (!batchId) {
  251. throw new Error('请选择抢课批次')
  252. }
  253. return batchId
  254. }
  255. async releaseAssignedTask(task, now = Date.now()) {
  256. if (!task || ![TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  257. return false
  258. }
  259. if (task.assigned_client_id) {
  260. await this.decrementClientSlots(task.assigned_client_id, now)
  261. }
  262. return true
  263. }
  264. async pauseTasksByBatch(batchId, reason = '批次已停用,任务已收回') {
  265. const rows = await db.query(
  266. 'SELECT id, status, assigned_client_id FROM qk_task WHERE batch_id = ? AND status IN (?, ?, ?)',
  267. [batchId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  268. )
  269. const now = Date.now()
  270. let count = 0
  271. for (const row of rows || []) {
  272. await this.releaseAssignedTask(row, now)
  273. const result = await db.query(
  274. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  275. assigned_at = NULL, exclude_client_id = NULL, update_time = ? WHERE id = ? AND status IN (?, ?, ?)`,
  276. [TASK_STATUS.PAUSED, now, row.id, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  277. )
  278. if (result && result.affectedRows > 0) {
  279. count += 1
  280. await this.logTask(row.id, row.assigned_client_id, 'batch_paused', reason)
  281. }
  282. }
  283. if (count > 0) {
  284. this.logInfo('pauseTasksByBatch', reason, { batchId }, { affected: count })
  285. }
  286. return count
  287. }
  288. async redispatchTasksByBatch(batchId, reason = '批次 ID 已变更,任务已重新进入分配队列') {
  289. const rows = await db.query(
  290. 'SELECT id, status, assigned_client_id FROM qk_task WHERE batch_id = ? AND status IN (?, ?, ?)',
  291. [batchId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  292. )
  293. const now = Date.now()
  294. let count = 0
  295. for (const row of rows || []) {
  296. if ([TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(row.status)) {
  297. await this.releaseAssignedTask(row, now)
  298. const result = await db.query(
  299. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  300. assigned_at = NULL, exclude_client_id = NULL, update_time = ? WHERE id = ? AND status IN (?, ?)`,
  301. [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  302. )
  303. if (result && result.affectedRows > 0) {
  304. count += 1
  305. await this.logTask(row.id, row.assigned_client_id, 'batch_redispatch', reason)
  306. }
  307. }
  308. }
  309. if (count > 0) {
  310. this.logInfo('redispatchTasksByBatch', reason, { batchId }, { affected: count })
  311. }
  312. return count
  313. }
  314. serializeBatch(row) {
  315. return {
  316. id: row.id,
  317. name: row.name,
  318. jx0502zbid: row.jx0502zbid,
  319. enabled: Number(row.enabled) === 1,
  320. create_time: row.create_time,
  321. update_time: row.update_time
  322. }
  323. }
  324. async listBatches(options = {}) {
  325. const enabledOnly = !!options.enabledOnly
  326. const where = enabledOnly ? 'WHERE enabled = 1' : ''
  327. const rows = await db.query(`SELECT * FROM qk_batch ${where} ORDER BY update_time DESC, id DESC`)
  328. return (rows || []).map(row => this.serializeBatch(row))
  329. }
  330. async createBatch(payload = {}) {
  331. const name = String(payload.name || '').trim()
  332. const jx0502zbid = String(payload.jx0502zbid || '').trim()
  333. if (!name || !jx0502zbid) {
  334. throw new Error('批次名称和批次 ID 不能为空')
  335. }
  336. const now = Date.now()
  337. const result = await db.query(
  338. 'INSERT INTO qk_batch (name, jx0502zbid, enabled, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
  339. [name, jx0502zbid, payload.enabled === false ? 0 : 1, now, now]
  340. )
  341. if (!result || result.affectedRows <= 0) {
  342. throw new Error('创建抢课批次失败')
  343. }
  344. this.logInfo('createBatch', '抢课批次已创建', { batchId: result.insertId }, { name, jx0502zbid })
  345. return result.insertId
  346. }
  347. async updateBatch(batchId, payload = {}) {
  348. const existing = await this.getBatchById(batchId)
  349. const name = payload.name !== undefined ? String(payload.name || '').trim() : existing.name
  350. const jx0502zbid = payload.jx0502zbid !== undefined ? String(payload.jx0502zbid || '').trim() : existing.jx0502zbid
  351. const enabled = payload.enabled !== undefined ? (payload.enabled ? 1 : 0) : Number(existing.enabled)
  352. if (!name || !jx0502zbid) {
  353. throw new Error('批次名称和批次 ID 不能为空')
  354. }
  355. const now = Date.now()
  356. const result = await db.query(
  357. 'UPDATE qk_batch SET name = ?, jx0502zbid = ?, enabled = ?, update_time = ? WHERE id = ?',
  358. [name, jx0502zbid, enabled, now, batchId]
  359. )
  360. if (!result || result.affectedRows <= 0) {
  361. throw new Error('更新抢课批次失败')
  362. }
  363. const disabledNow = Number(existing.enabled) === 1 && enabled === 0
  364. const idChanged = String(existing.jx0502zbid) !== jx0502zbid
  365. if (disabledNow) {
  366. await this.pauseTasksByBatch(batchId)
  367. } else if (idChanged && enabled === 1) {
  368. await this.redispatchTasksByBatch(batchId)
  369. }
  370. this.logInfo('updateBatch', '抢课批次已更新', { batchId }, {
  371. name,
  372. jx0502zbid,
  373. enabled: enabled === 1,
  374. disabledNow,
  375. idChanged
  376. })
  377. }
  378. async logTask(taskId, clientId, event, message = '', payload = null) {
  379. const sql = 'INSERT INTO qk_task_log (task_id, client_id, event, message, payload_json, create_time) VALUES (?, ?, ?, ?, ?, ?)'
  380. await db.query(sql, [
  381. taskId,
  382. clientId || null,
  383. event,
  384. message || '',
  385. payload ? JSON.stringify(payload) : null,
  386. Date.now()
  387. ])
  388. this.logInfo('taskLog', message || event, { taskId, clientId }, payload ? { event, ...this.sanitizeForLog(payload) } : { event })
  389. }
  390. serializeReportLog(row) {
  391. const result = { ...row }
  392. if (typeof result.payload_json === 'string' && result.payload_json) {
  393. try {
  394. result.payload_json = JSON.parse(result.payload_json)
  395. } catch (_) {}
  396. }
  397. return result
  398. }
  399. buildReportMessage(payload = {}) {
  400. if (payload.message) {
  401. return String(payload.message)
  402. }
  403. if (payload.error_msg) {
  404. return String(payload.error_msg)
  405. }
  406. if (payload.error) {
  407. return String(payload.error)
  408. }
  409. if (payload.success === true) {
  410. return payload.label ? `${payload.label} 成功` : '请求成功'
  411. }
  412. return payload.label ? `${payload.label} 失败` : '请求失败'
  413. }
  414. async assertClientTaskAccess(clientId, taskId) {
  415. const rows = await db.query(
  416. 'SELECT id, status, assigned_client_id FROM qk_task WHERE id = ?',
  417. [taskId]
  418. )
  419. if (!rows || rows.length === 0) {
  420. throw new Error('任务不存在')
  421. }
  422. const task = rows[0]
  423. if (task.assigned_client_id !== clientId) {
  424. throw new Error('任务不属于当前客户端')
  425. }
  426. if (![TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  427. throw new Error('任务当前状态不可上报')
  428. }
  429. return task
  430. }
  431. async reportProgress(clientId, clientSecret, payload = {}) {
  432. const client = await this.authenticateClient(clientId, clientSecret)
  433. if (!client) {
  434. throw new Error('客户端凭证无效')
  435. }
  436. const taskId = Number(payload.task_id || payload.id)
  437. if (!taskId) {
  438. throw new Error('缺少任务 ID')
  439. }
  440. const event = String(payload.event || 'request_result')
  441. if (!REPORT_LOG_EVENTS.has(event) || event === 'grab_success' || event === 'grab_fail') {
  442. throw new Error('不支持的上报类型')
  443. }
  444. const task = await this.assertClientTaskAccess(clientId, taskId)
  445. const message = this.buildReportMessage(payload)
  446. const now = Date.now()
  447. await this.logTask(taskId, clientId, event, message, payload)
  448. const updates = ['update_time = ?']
  449. const params = [now]
  450. if (task.status === TASK_STATUS.ASSIGNED) {
  451. updates.push('status = ?')
  452. params.push(TASK_STATUS.RUNNING)
  453. }
  454. if (payload.success !== true && message) {
  455. updates.push('error_msg = ?')
  456. params.push(message)
  457. }
  458. params.push(taskId, clientId)
  459. await db.query(
  460. `UPDATE qk_task SET ${updates.join(', ')} WHERE id = ? AND assigned_client_id = ?`,
  461. params
  462. )
  463. this.logInfo('reportProgress', '客户端上报抢课进度', { taskId, clientId }, {
  464. event,
  465. success: payload.success === true,
  466. message
  467. })
  468. return { task_id: taskId, event, message }
  469. }
  470. async createTask(uuid, payload) {
  471. const validated = this.validateTaskCoursesAndInterval(
  472. payload.courses || payload.COURSES,
  473. payload.course_groups || payload.COURSE_GROUPS,
  474. payload.interval_ms || payload.INTERVAL_MS || 500
  475. )
  476. const { courses, courseGroups, intervalMs } = validated
  477. const batchId = this.resolveTaskBatchId(payload)
  478. await this.requireEnabledBatch(batchId)
  479. const time = Date.now()
  480. const sql = `INSERT INTO qk_task
  481. (create_user, name, batch_id, jx0502zbid, student_num, password_enc, courses, course_groups, enable_ggxxk, interval_ms, status, create_time, update_time)
  482. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
  483. const result = await db.query(sql, [
  484. uuid,
  485. payload.name,
  486. batchId,
  487. '',
  488. payload.student_num || payload.user,
  489. this.encryptPassword(payload.password || payload.pass),
  490. JSON.stringify(courses),
  491. JSON.stringify(courseGroups),
  492. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  493. intervalMs,
  494. TASK_STATUS.PAUSED,
  495. time,
  496. time
  497. ])
  498. if (!result || result.affectedRows <= 0) {
  499. throw new Error('创建抢课任务失败')
  500. }
  501. await this.logTask(result.insertId, null, 'created', '用户提交抢课任务(未开始)')
  502. this.logInfo('createTask', '抢课任务已创建', { taskId: result.insertId, uuid }, {
  503. name: payload.name,
  504. batch_id: batchId,
  505. student_num: payload.student_num || payload.user,
  506. courses_count: courses.length,
  507. course_groups_count: courseGroups.length,
  508. interval_ms: intervalMs,
  509. enable_ggxxk: !!(payload.enable_ggxxk || payload.ENABLE_GGXXK)
  510. })
  511. return result.insertId
  512. }
  513. async updateTask(uuid, taskId, payload) {
  514. const rows = await db.query('SELECT status FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  515. if (!rows || rows.length === 0) {
  516. throw new Error('任务不存在')
  517. }
  518. if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
  519. throw new Error('任务进行中或已分配,请先暂停后再修改')
  520. }
  521. const validated = this.validateTaskCoursesAndInterval(
  522. payload.courses || payload.COURSES,
  523. payload.course_groups || payload.COURSE_GROUPS,
  524. payload.interval_ms || payload.INTERVAL_MS || 500
  525. )
  526. const { courses, courseGroups, intervalMs } = validated
  527. const batchId = this.resolveTaskBatchId(payload)
  528. await this.requireEnabledBatch(batchId)
  529. const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
  530. const params = [
  531. payload.name,
  532. batchId,
  533. payload.student_num || payload.user,
  534. JSON.stringify(courses),
  535. JSON.stringify(courseGroups),
  536. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  537. intervalMs,
  538. TASK_STATUS.PAUSED,
  539. Date.now()
  540. ]
  541. if (passwordSql) {
  542. params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
  543. }
  544. params.push(taskId, uuid)
  545. 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 = ?`
  546. const result = await db.query(sql, params)
  547. if (!result || result.affectedRows <= 0) {
  548. throw new Error('更新抢课任务失败')
  549. }
  550. await this.logTask(taskId, null, 'updated', '用户更新抢课任务')
  551. this.logInfo('updateTask', '抢课任务已更新(未开始)', { taskId, uuid }, {
  552. name: payload.name,
  553. student_num: payload.student_num || payload.user,
  554. courses_count: courses.length,
  555. course_groups_count: courseGroups.length,
  556. interval_ms: intervalMs,
  557. password_changed: !!(payload.password || payload.pass)
  558. })
  559. }
  560. async listUserTasks(uuid, filters = {}) {
  561. const where = ['t.create_user = ?']
  562. const params = [uuid]
  563. if (filters.status) {
  564. where.push('t.status = ?')
  565. params.push(filters.status)
  566. }
  567. if (filters.student_num) {
  568. where.push('t.student_num LIKE ?')
  569. params.push(`%${filters.student_num}%`)
  570. }
  571. if (filters.name) {
  572. where.push('t.name LIKE ?')
  573. params.push(`%${filters.name}%`)
  574. }
  575. if (filters.batch_id) {
  576. where.push('t.batch_id = ?')
  577. params.push(Number(filters.batch_id))
  578. }
  579. const whereSql = where.join(' AND ')
  580. const rows = await db.query(
  581. `SELECT t.*${this.taskBatchSelectSql('t')}
  582. FROM qk_task t
  583. ${this.taskBatchJoinSql('t')}
  584. WHERE ${whereSql}
  585. ORDER BY t.create_time DESC`,
  586. params
  587. )
  588. return (rows || []).map(row => this.serializeTask(row))
  589. }
  590. async getTaskDetail(uuid, taskId) {
  591. const rows = await db.query(
  592. `SELECT t.*${this.taskBatchSelectSql('t')}
  593. FROM qk_task t
  594. ${this.taskBatchJoinSql('t')}
  595. WHERE t.id = ? AND t.create_user = ?`,
  596. [taskId, uuid]
  597. )
  598. if (!rows || rows.length === 0) {
  599. return null
  600. }
  601. 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])
  602. return {
  603. task: this.serializeTask(rows[0], true),
  604. logs: (logs || []).map(log => {
  605. if (typeof log.payload_json === 'string' && log.payload_json) {
  606. try {
  607. log.payload_json = JSON.parse(log.payload_json)
  608. } catch (_) {}
  609. }
  610. return log
  611. })
  612. }
  613. }
  614. async cancelTask(uuid, taskId) {
  615. const result = await db.query(
  616. 'UPDATE qk_task SET status = ?, update_time = ?, finished_time = ? WHERE id = ? AND create_user = ? AND status IN (?, ?, ?)',
  617. [TASK_STATUS.CANCELLED, Date.now(), Date.now(), taskId, uuid, TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.FAILED]
  618. )
  619. if (!result || result.affectedRows <= 0) {
  620. throw new Error('任务不存在或当前状态不可取消')
  621. }
  622. await this.logTask(taskId, null, 'cancelled', '用户取消抢课任务')
  623. this.logInfo('cancelTask', '用户已取消抢课任务', { taskId, uuid })
  624. }
  625. async startTask(uuid, taskId) {
  626. const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  627. if (!rows || rows.length === 0) {
  628. throw new Error('任务不存在')
  629. }
  630. const task = rows[0]
  631. if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED].includes(task.status)) {
  632. throw new Error('仅未开始或失败的任务可开启')
  633. }
  634. if (task.batch_id) {
  635. await this.requireEnabledBatch(task.batch_id)
  636. }
  637. const now = Date.now()
  638. const result = await db.query(
  639. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  640. assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL
  641. WHERE id = ? AND create_user = ? AND status IN (?, ?)`,
  642. [TASK_STATUS.PENDING, now, taskId, uuid, TASK_STATUS.PAUSED, TASK_STATUS.FAILED]
  643. )
  644. if (!result || result.affectedRows <= 0) {
  645. throw new Error('开启抢课任务失败')
  646. }
  647. await this.logTask(taskId, null, 'started', '用户开启抢课任务,等待分配')
  648. this.logInfo('startTask', '用户已开启抢课任务', { taskId, uuid })
  649. }
  650. async pauseTask(uuid, taskId) {
  651. const rows = await db.query('SELECT * FROM qk_task WHERE id = ? AND create_user = ?', [taskId, uuid])
  652. if (!rows || rows.length === 0) {
  653. throw new Error('任务不存在')
  654. }
  655. const task = rows[0]
  656. if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  657. throw new Error('当前状态不可暂停')
  658. }
  659. const now = Date.now()
  660. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  661. if (wasAssigned && task.assigned_client_id) {
  662. await this.decrementClientSlots(task.assigned_client_id, now)
  663. }
  664. const result = await db.query(
  665. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  666. assigned_at = NULL, exclude_client_id = NULL
  667. WHERE id = ? AND create_user = ? AND status IN (?, ?, ?)`,
  668. [TASK_STATUS.PAUSED, now, taskId, uuid, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  669. )
  670. if (!result || result.affectedRows <= 0) {
  671. throw new Error('暂停抢课任务失败')
  672. }
  673. await this.logTask(taskId, task.assigned_client_id, 'paused', wasAssigned ? '用户暂停任务,已收回客户端' : '用户暂停抢课任务')
  674. this.logInfo('pauseTask', '用户已暂停抢课任务', { taskId, uuid }, {
  675. released_from_client: wasAssigned ? task.assigned_client_id : null
  676. })
  677. }
  678. async getAdminTask(taskId) {
  679. const rows = await db.query(
  680. `SELECT t.*, u.username, u.avatar${this.taskBatchSelectSql('t')}
  681. FROM qk_task t
  682. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  683. ${this.taskBatchJoinSql('t')}
  684. WHERE t.id = ?`,
  685. [taskId]
  686. )
  687. if (!rows || rows.length === 0) {
  688. return null
  689. }
  690. return this.serializeTask(rows[0], true)
  691. }
  692. async decrementClientSlots(clientId, now = Date.now()) {
  693. if (!clientId) return
  694. await db.query(
  695. 'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
  696. [now, clientId]
  697. )
  698. }
  699. async adminUpdateTask(taskId, payload) {
  700. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  701. if (!rows || rows.length === 0) {
  702. throw new Error('任务不存在')
  703. }
  704. const task = rows[0]
  705. if (task.status === TASK_STATUS.SUCCESS) {
  706. throw new Error('已成功的任务不可编辑')
  707. }
  708. const validated = this.validateTaskCoursesAndInterval(
  709. payload.courses || payload.COURSES,
  710. payload.course_groups || payload.COURSE_GROUPS,
  711. payload.interval_ms || payload.INTERVAL_MS || task.interval_ms || 500
  712. )
  713. const { courses, courseGroups, intervalMs } = validated
  714. const now = Date.now()
  715. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  716. if (wasAssigned) {
  717. await this.decrementClientSlots(task.assigned_client_id, now)
  718. }
  719. const batchId = this.resolveTaskBatchId(payload)
  720. const passwordSql = payload.password || payload.pass ? ', password_enc = ?' : ''
  721. const params = [
  722. payload.name,
  723. batchId,
  724. payload.student_num || payload.user,
  725. JSON.stringify(courses),
  726. JSON.stringify(courseGroups),
  727. payload.enable_ggxxk || payload.ENABLE_GGXXK ? 1 : 0,
  728. intervalMs,
  729. TASK_STATUS.PAUSED,
  730. now
  731. ]
  732. if (passwordSql) {
  733. params.splice(3, 0, this.encryptPassword(payload.password || payload.pass))
  734. }
  735. params.push(taskId)
  736. 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 = ?`
  737. const result = await db.query(sql, params)
  738. if (!result || result.affectedRows <= 0) {
  739. throw new Error('更新抢课任务失败')
  740. }
  741. await this.logTask(taskId, task.assigned_client_id, 'admin_updated', wasAssigned ? '管理员更新任务并收回(未开始)' : '管理员更新抢课任务')
  742. this.logInfo('adminUpdateTask', '管理员已更新抢课任务', { taskId }, {
  743. name: payload.name,
  744. student_num: payload.student_num || payload.user,
  745. released_from_client: wasAssigned ? task.assigned_client_id : null
  746. })
  747. }
  748. async adminCancelTask(taskId) {
  749. const rows = await db.query('SELECT id, status, assigned_client_id FROM qk_task WHERE id = ?', [taskId])
  750. if (!rows || rows.length === 0) {
  751. throw new Error('任务不存在')
  752. }
  753. const task = rows[0]
  754. if (![TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED].includes(task.status)) {
  755. throw new Error('当前状态不可取消')
  756. }
  757. const now = Date.now()
  758. if ([TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  759. await this.decrementClientSlots(task.assigned_client_id, now)
  760. }
  761. const result = await db.query(
  762. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
  763. exclude_client_id = NULL, update_time = ?, finished_time = ? WHERE id = ? AND status IN (?, ?, ?, ?, ?)`,
  764. [TASK_STATUS.CANCELLED, now, now, taskId, TASK_STATUS.PAUSED, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, TASK_STATUS.FAILED]
  765. )
  766. if (!result || result.affectedRows <= 0) {
  767. throw new Error('取消抢课任务失败')
  768. }
  769. await this.logTask(taskId, task.assigned_client_id, 'admin_cancelled', '管理员取消抢课任务')
  770. this.logInfo('adminCancelTask', '管理员已取消抢课任务', { taskId })
  771. }
  772. async adminRetryTask(taskId) {
  773. const rows = await db.query('SELECT id, status FROM qk_task WHERE id = ?', [taskId])
  774. if (!rows || rows.length === 0) {
  775. throw new Error('任务不存在')
  776. }
  777. if (![TASK_STATUS.FAILED, TASK_STATUS.CANCELLED].includes(rows[0].status)) {
  778. throw new Error('仅失败或已取消的任务可重试')
  779. }
  780. const now = Date.now()
  781. const result = await db.query(
  782. `UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL,
  783. exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL, update_time = ?
  784. WHERE id = ? AND status IN (?, ?)`,
  785. [TASK_STATUS.PENDING, now, taskId, TASK_STATUS.FAILED, TASK_STATUS.CANCELLED]
  786. )
  787. if (!result || result.affectedRows <= 0) {
  788. throw new Error('重试抢课任务失败')
  789. }
  790. await this.logTask(taskId, null, 'admin_retry', '管理员将任务重新加入待分配队列')
  791. this.logInfo('adminRetryTask', '管理员已重试抢课任务', { taskId })
  792. }
  793. async adminStartTask(taskId) {
  794. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  795. if (!rows || rows.length === 0) {
  796. throw new Error('任务不存在')
  797. }
  798. const task = rows[0]
  799. if (![TASK_STATUS.PAUSED, TASK_STATUS.FAILED].includes(task.status)) {
  800. throw new Error('仅未开始或失败的任务可开启')
  801. }
  802. if (task.batch_id) {
  803. await this.requireEnabledBatch(task.batch_id)
  804. }
  805. const now = Date.now()
  806. const result = await db.query(
  807. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  808. assigned_at = NULL, exclude_client_id = NULL, result_json = NULL, error_msg = NULL, finished_time = NULL
  809. WHERE id = ? AND status IN (?, ?)`,
  810. [TASK_STATUS.PENDING, now, taskId, TASK_STATUS.PAUSED, TASK_STATUS.FAILED]
  811. )
  812. if (!result || result.affectedRows <= 0) {
  813. throw new Error('开启抢课任务失败')
  814. }
  815. await this.logTask(taskId, null, 'admin_started', '管理员开启抢课任务,等待分配')
  816. this.logInfo('adminStartTask', '管理员已开启抢课任务', { taskId })
  817. }
  818. async adminPauseTask(taskId) {
  819. const rows = await db.query('SELECT * FROM qk_task WHERE id = ?', [taskId])
  820. if (!rows || rows.length === 0) {
  821. throw new Error('任务不存在')
  822. }
  823. const task = rows[0]
  824. if (![TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)) {
  825. throw new Error('当前状态不可暂停')
  826. }
  827. const now = Date.now()
  828. const wasAssigned = [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING].includes(task.status)
  829. if (wasAssigned && task.assigned_client_id) {
  830. await this.decrementClientSlots(task.assigned_client_id, now)
  831. }
  832. const result = await db.query(
  833. `UPDATE qk_task SET status = ?, update_time = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  834. assigned_at = NULL, exclude_client_id = NULL
  835. WHERE id = ? AND status IN (?, ?, ?)`,
  836. [TASK_STATUS.PAUSED, now, taskId, TASK_STATUS.PENDING, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  837. )
  838. if (!result || result.affectedRows <= 0) {
  839. throw new Error('暂停抢课任务失败')
  840. }
  841. await this.logTask(taskId, task.assigned_client_id, 'admin_paused', wasAssigned ? '管理员暂停任务,已收回客户端' : '管理员暂停抢课任务')
  842. this.logInfo('adminPauseTask', '管理员已暂停抢课任务', { taskId }, {
  843. released_from_client: wasAssigned ? task.assigned_client_id : null
  844. })
  845. }
  846. async authenticateClient(clientId, clientSecret) {
  847. if (!clientId || !clientSecret) {
  848. this.logWarn('authClient', '客户端认证失败:缺少凭证', { clientId: clientId || 'unknown' })
  849. return null
  850. }
  851. const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
  852. if (!rows || rows.length !== 1) {
  853. this.logWarn('authClient', '客户端认证失败:客户端不存在或已禁用', { clientId })
  854. return null
  855. }
  856. if (!bcryptjs.compareSync(String(clientSecret), rows[0].client_secret_hash)) {
  857. this.logWarn('authClient', '客户端认证失败:密钥不匹配', { clientId })
  858. return null
  859. }
  860. return rows[0]
  861. }
  862. async enrollOrAuthenticateClient(clientId, clientSecret, payload = {}) {
  863. const existing = await this.authenticateClient(clientId, clientSecret)
  864. if (existing) {
  865. return existing
  866. }
  867. if (!clientId || !clientSecret || !String(clientId).startsWith('qk-cli-')) {
  868. throw new Error('客户端凭证无效')
  869. }
  870. const rows = await db.query('SELECT * FROM qk_client WHERE client_id = ?', [clientId])
  871. if (rows && rows.length > 0) {
  872. throw new Error('客户端凭证无效')
  873. }
  874. const time = Date.now()
  875. const label = payload.label || payload.hostname || `auto-${clientId}`
  876. try {
  877. const result = await db.query(
  878. 'INSERT INTO qk_client (client_id, client_secret_hash, label, create_time, update_time) VALUES (?, ?, ?, ?, ?)',
  879. [clientId, bcryptjs.hashSync(String(clientSecret), 10), label, time, time]
  880. )
  881. if (!result || result.affectedRows <= 0) {
  882. throw new Error('客户端自动注册失败')
  883. }
  884. } catch (err) {
  885. if (err?.code === 'ER_DUP_ENTRY') {
  886. const raced = await this.authenticateClient(clientId, clientSecret)
  887. if (raced) {
  888. return raced
  889. }
  890. }
  891. throw err
  892. }
  893. const created = await db.query('SELECT * FROM qk_client WHERE client_id = ? AND enabled = 1', [clientId])
  894. if (!created || created.length !== 1) {
  895. throw new Error('客户端自动注册失败')
  896. }
  897. this.logInfo('enrollClient', '抢课客户端已自动注册', { clientId }, { label })
  898. return created[0]
  899. }
  900. async registerClient(clientId, clientSecret, payload = {}) {
  901. const client = await this.enrollOrAuthenticateClient(clientId, clientSecret, payload)
  902. const maxSlots = this.resolveClientMaxSlots(client, payload)
  903. const currentSlots = Math.max(0, Number(payload.current_slots || 0))
  904. const time = Date.now()
  905. await db.query(
  906. '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 = ?',
  907. [
  908. payload.label || '',
  909. maxSlots,
  910. currentSlots,
  911. payload.hostname || null,
  912. payload.os_username || null,
  913. payload.cpu_model || null,
  914. payload.cpu_threads || null,
  915. payload.total_mem_mb || null,
  916. payload.free_mem_mb || null,
  917. payload.platform || null,
  918. time,
  919. time,
  920. clientId
  921. ]
  922. )
  923. await Redis.set(`qk:client:hb:${clientId}`, String(time), { EX: this.heartbeatTtlSeconds })
  924. this.logInfo('registerClient', '抢课客户端已注册/上线', { clientId }, {
  925. label: payload.label || client.label,
  926. max_slots: maxSlots,
  927. current_slots: currentSlots,
  928. hostname: payload.hostname,
  929. platform: payload.platform,
  930. cpu_threads: payload.cpu_threads,
  931. total_mem_mb: payload.total_mem_mb,
  932. free_mem_mb: payload.free_mem_mb
  933. })
  934. return { client_id: clientId, max_slots: maxSlots, current_slots: currentSlots }
  935. }
  936. async heartbeat(clientId, clientSecret, payload = {}) {
  937. const client = await this.authenticateClient(clientId, clientSecret)
  938. if (!client) {
  939. throw new Error('客户端凭证无效')
  940. }
  941. const runningTasks = Array.isArray(payload.running_tasks) ? payload.running_tasks.map(Number).filter(Boolean) : []
  942. const currentSlots = Math.max(0, Number(payload.current_slots ?? runningTasks.length))
  943. const maxSlots = this.resolveClientMaxSlots(client, payload)
  944. const now = Date.now()
  945. await db.query(
  946. '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 = ?',
  947. [
  948. maxSlots,
  949. currentSlots,
  950. payload.hostname || null,
  951. payload.os_username || null,
  952. payload.cpu_model || null,
  953. payload.cpu_threads || null,
  954. payload.total_mem_mb || null,
  955. payload.free_mem_mb || null,
  956. payload.platform || null,
  957. now,
  958. now,
  959. clientId
  960. ]
  961. )
  962. await Redis.set(`qk:client:hb:${clientId}`, String(now), { EX: this.heartbeatTtlSeconds })
  963. await Redis.set(`qk:client:slots:${clientId}`, String(currentSlots), { EX: this.heartbeatTtlSeconds })
  964. if (runningTasks.length > 0) {
  965. const leaseExpireAt = now + this.leaseMs
  966. const placeholders = runningTasks.map(() => '?').join(',')
  967. await db.query(
  968. `UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
  969. [TASK_STATUS.RUNNING, leaseExpireAt, now, clientId, ...runningTasks, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  970. )
  971. this.logInfo('heartbeat', '客户端心跳续租运行中任务', { clientId }, {
  972. running_tasks: runningTasks,
  973. lease_expire_at: leaseExpireAt,
  974. current_slots: currentSlots,
  975. max_slots: maxSlots
  976. })
  977. }
  978. await this.releaseOrphanedClientTasks(clientId, runningTasks, now)
  979. return { client_id: clientId, current_slots: currentSlots, max_slots: maxSlots }
  980. }
  981. async releaseOrphanedClientTasks(clientId, runningTasks, now = Date.now()) {
  982. const assigned = await db.query(
  983. 'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  984. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  985. )
  986. const runningSet = new Set((runningTasks || []).map(Number).filter(Boolean))
  987. let released = 0
  988. for (const row of assigned || []) {
  989. if (runningSet.has(row.id)) continue
  990. const result = await db.query(
  991. '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 (?, ?)',
  992. [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  993. )
  994. if (result && result.affectedRows > 0) {
  995. released += 1
  996. await this.logTask(row.id, clientId, 'released', '客户端未继续执行,任务已释放回队列')
  997. this.logInfo('releaseOrphaned', '释放未在运行的已分配任务', { taskId: row.id, clientId })
  998. }
  999. }
  1000. return released
  1001. }
  1002. async reclaimTasks(clientId, clientSecret) {
  1003. const client = await this.authenticateClient(clientId, clientSecret)
  1004. if (!client) {
  1005. throw new Error('客户端凭证无效')
  1006. }
  1007. const now = Date.now()
  1008. const leaseExpireAt = now + this.leaseMs
  1009. const rows = await db.query(
  1010. `SELECT t.*, b.jx0502zbid AS batch_jx0502zbid, b.name AS batch_name, b.enabled AS batch_enabled
  1011. FROM qk_task t
  1012. LEFT JOIN qk_batch b ON b.id = t.batch_id
  1013. WHERE t.assigned_client_id = ? AND t.status IN (?, ?)
  1014. ORDER BY t.create_time ASC`,
  1015. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1016. )
  1017. if (!rows || rows.length === 0) {
  1018. return []
  1019. }
  1020. for (const row of rows) {
  1021. await db.query(
  1022. 'UPDATE qk_task SET status = ?, lease_expire_at = ?, update_time = ? WHERE id = ? AND assigned_client_id = ? AND status IN (?, ?)',
  1023. [TASK_STATUS.RUNNING, leaseExpireAt, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1024. )
  1025. await this.logTask(row.id, clientId, 'reclaimed', '客户端重连后回收任务继续执行')
  1026. }
  1027. this.logInfo('reclaimTasks', `客户端回收 ${rows.length} 个进行中任务`, { clientId }, {
  1028. task_ids: rows.map(row => row.id),
  1029. lease_expire_at: leaseExpireAt
  1030. })
  1031. return rows.map(row => this.serializeTask({
  1032. ...row,
  1033. assigned_client_id: clientId,
  1034. lease_expire_at: leaseExpireAt,
  1035. status: TASK_STATUS.RUNNING
  1036. }, true))
  1037. }
  1038. async releaseTasks(clientId, clientSecret, payload = {}) {
  1039. const client = await this.authenticateClient(clientId, clientSecret)
  1040. if (!client) {
  1041. throw new Error('客户端凭证无效')
  1042. }
  1043. const now = Date.now()
  1044. const taskIds = Array.isArray(payload.task_ids)
  1045. ? payload.task_ids.map(Number).filter(Boolean)
  1046. : []
  1047. let rows = []
  1048. if (taskIds.length > 0) {
  1049. const placeholders = taskIds.map(() => '?').join(',')
  1050. rows = await db.query(
  1051. `SELECT id FROM qk_task WHERE assigned_client_id = ? AND id IN (${placeholders}) AND status IN (?, ?)`,
  1052. [clientId, ...taskIds, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1053. )
  1054. } else {
  1055. rows = await db.query(
  1056. 'SELECT id FROM qk_task WHERE assigned_client_id = ? AND status IN (?, ?)',
  1057. [clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1058. )
  1059. }
  1060. let released = 0
  1061. for (const row of rows || []) {
  1062. const result = await db.query(
  1063. '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 (?, ?)',
  1064. [TASK_STATUS.PENDING, now, row.id, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1065. )
  1066. if (result && result.affectedRows > 0) {
  1067. released += 1
  1068. await this.logTask(row.id, clientId, 'released', '客户端主动释放任务')
  1069. }
  1070. }
  1071. if (released > 0) {
  1072. this.logInfo('releaseTasks', `客户端释放 ${released} 个任务`, { clientId }, {
  1073. task_ids: (rows || []).map(row => row.id)
  1074. })
  1075. }
  1076. return { released, task_ids: (rows || []).map(row => row.id) }
  1077. }
  1078. async pullTasks(clientId, clientSecret, count) {
  1079. const client = await this.authenticateClient(clientId, clientSecret)
  1080. if (!client) {
  1081. throw new Error('客户端凭证无效')
  1082. }
  1083. const availableSlots = this.getClientAvailableSlots(client)
  1084. const safeCount = Math.max(0, Math.min(50, Number(count || 0), availableSlots))
  1085. if (safeCount <= 0) {
  1086. this.logWarn('pullTasks', '客户端无可用槽位或拉取数量无效,已忽略', { clientId }, {
  1087. count,
  1088. available_slots: availableSlots,
  1089. max_slots: this.resolveClientMaxSlots(client),
  1090. current_slots: client.current_slots,
  1091. free_mem_mb: client.free_mem_mb
  1092. })
  1093. return []
  1094. }
  1095. const lockKey = `qk:pull:lock:${clientId}`
  1096. const locked = await Redis.set(lockKey, '1', { NX: true, EX: this.pullLockTtlSeconds })
  1097. if (!locked) {
  1098. this.logWarn('pullTasks', '拉取任务被并发锁拦截,本次跳过', { clientId }, { count: safeCount })
  1099. return []
  1100. }
  1101. const conn = await db.connect()
  1102. try {
  1103. await conn.beginTransaction()
  1104. const inFlightStatuses = this.getInFlightTaskStatuses()
  1105. const candidateLimit = Math.min(50, Math.max(safeCount, safeCount * 5))
  1106. const [candidateRows] = await conn.execute(
  1107. `SELECT t.*, b.jx0502zbid AS batch_jx0502zbid, b.name AS batch_name, b.enabled AS batch_enabled
  1108. FROM qk_task t
  1109. LEFT JOIN qk_batch b ON b.id = t.batch_id
  1110. WHERE t.status = ?
  1111. AND (t.batch_id IS NULL OR b.enabled = 1)
  1112. AND (t.exclude_client_id IS NULL OR t.exclude_client_id <> ?)
  1113. AND NOT EXISTS (
  1114. SELECT 1 FROM qk_task active
  1115. WHERE active.student_num = t.student_num
  1116. AND active.status IN (?, ?)
  1117. )
  1118. ORDER BY t.create_time ASC LIMIT ${candidateLimit} FOR UPDATE`,
  1119. [TASK_STATUS.PENDING, clientId, ...inFlightStatuses]
  1120. )
  1121. const rows = this.pickPullableTasks(candidateRows, safeCount)
  1122. const now = Date.now()
  1123. const leaseExpireAt = now + this.leaseMs
  1124. for (const row of rows) {
  1125. await conn.execute(
  1126. 'UPDATE qk_task SET status = ?, assigned_client_id = ?, lease_expire_at = ?, assigned_at = ?, exclude_client_id = NULL, update_time = ? WHERE id = ?',
  1127. [TASK_STATUS.ASSIGNED, clientId, leaseExpireAt, now, now, row.id]
  1128. )
  1129. }
  1130. await conn.commit()
  1131. if (rows.length > 0) {
  1132. await db.query(
  1133. 'UPDATE qk_client SET current_slots = current_slots + ?, update_time = ? WHERE client_id = ?',
  1134. [rows.length, now, clientId]
  1135. )
  1136. }
  1137. for (const row of rows) {
  1138. await this.logTask(row.id, clientId, 'assigned', '任务已分配给客户端')
  1139. }
  1140. if (rows.length > 0) {
  1141. this.logInfo('pullTasks', `已分配 ${rows.length} 个抢课任务`, { clientId }, {
  1142. task_ids: rows.map(row => row.id),
  1143. lease_expire_at: leaseExpireAt,
  1144. requested_count: safeCount
  1145. })
  1146. }
  1147. return rows.map(row => this.serializeTask({ ...row, assigned_client_id: clientId, lease_expire_at: leaseExpireAt, status: TASK_STATUS.ASSIGNED }, true))
  1148. } catch (err) {
  1149. await conn.rollback()
  1150. this.logError('pullTasks', '拉取并分配任务失败,事务已回滚', { clientId }, err)
  1151. throw err
  1152. } finally {
  1153. await Redis.del(lockKey)
  1154. }
  1155. }
  1156. async reportResult(clientId, clientSecret, payload = {}) {
  1157. const client = await this.authenticateClient(clientId, clientSecret)
  1158. if (!client) {
  1159. throw new Error('客户端凭证无效')
  1160. }
  1161. const taskId = Number(payload.task_id || payload.id)
  1162. const success = payload.success === true || payload.status === TASK_STATUS.SUCCESS
  1163. const status = success ? TASK_STATUS.SUCCESS : TASK_STATUS.FAILED
  1164. const now = Date.now()
  1165. const resultJson = payload.result ? JSON.stringify(payload.result) : JSON.stringify({
  1166. course: payload.course || '',
  1167. message: payload.message || ''
  1168. })
  1169. const result = await db.query(
  1170. '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 (?, ?)',
  1171. [status, resultJson, success ? null : (payload.error_msg || payload.message || '抢课失败'), now, now, taskId, clientId, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1172. )
  1173. if (!result || result.affectedRows <= 0) {
  1174. this.logWarn('reportResult', '任务结果上报被拒绝:任务不存在或不属于当前客户端', { taskId, clientId }, {
  1175. success,
  1176. status
  1177. })
  1178. throw new Error('任务不存在或不属于当前客户端')
  1179. }
  1180. await db.query(
  1181. `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 = ?`,
  1182. [success ? 1 : 0, now, clientId]
  1183. )
  1184. await this.logTask(taskId, clientId, success ? 'grab_success' : 'grab_fail', payload.message || payload.error_msg || '', payload.result || payload)
  1185. this.logInfo('reportResult', success ? '抢课成功' : '抢课失败', { taskId, clientId }, {
  1186. success,
  1187. message: payload.message || payload.error_msg || '',
  1188. course: payload.course || payload.result?.course || '',
  1189. result: payload.result || null
  1190. })
  1191. return { task_id: taskId, status }
  1192. }
  1193. async listClients() {
  1194. 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')
  1195. return rows || []
  1196. }
  1197. async deleteClient(clientId) {
  1198. const result = await db.query('UPDATE qk_client SET enabled = 0, online = 0, update_time = ? WHERE client_id = ?', [Date.now(), clientId])
  1199. if (!result || result.affectedRows <= 0) {
  1200. throw new Error('客户端不存在')
  1201. }
  1202. this.logInfo('deleteClient', '抢课客户端已禁用', { clientId })
  1203. }
  1204. async listAdminTasks(filters = {}) {
  1205. const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
  1206. const current = Math.max(1, Number(filters.current || 1))
  1207. const where = ['1 = 1']
  1208. const params = []
  1209. const countParams = []
  1210. if (filters.status) {
  1211. where.push('t.status = ?')
  1212. params.push(filters.status)
  1213. countParams.push(filters.status)
  1214. }
  1215. if (filters.client_id) {
  1216. where.push('t.assigned_client_id = ?')
  1217. params.push(filters.client_id)
  1218. countParams.push(filters.client_id)
  1219. }
  1220. if (filters.student_num) {
  1221. where.push('t.student_num LIKE ?')
  1222. params.push(`%${filters.student_num}%`)
  1223. countParams.push(`%${filters.student_num}%`)
  1224. }
  1225. if (filters.username) {
  1226. where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
  1227. params.push(`%${filters.username}%`)
  1228. countParams.push(`%${filters.username}%`)
  1229. }
  1230. if (filters.batch_id) {
  1231. where.push('t.batch_id = ?')
  1232. params.push(Number(filters.batch_id))
  1233. countParams.push(Number(filters.batch_id))
  1234. }
  1235. const whereSql = where.join(' AND ')
  1236. const offset = (current - 1) * pagesize
  1237. const countRows = await db.query(
  1238. `SELECT COUNT(*) AS total FROM qk_task t
  1239. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  1240. WHERE ${whereSql}`,
  1241. countParams
  1242. )
  1243. const rows = await db.query(
  1244. `SELECT t.*, u.username, u.avatar${this.taskBatchSelectSql('t')}
  1245. FROM qk_task t
  1246. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci
  1247. ${this.taskBatchJoinSql('t')}
  1248. WHERE ${whereSql}
  1249. ORDER BY t.create_time DESC
  1250. LIMIT ${pagesize} OFFSET ${offset}`,
  1251. params
  1252. )
  1253. return {
  1254. list: (rows || []).map(row => this.serializeTask(row, true)),
  1255. total: countRows?.[0]?.total || 0,
  1256. current,
  1257. pagesize
  1258. }
  1259. }
  1260. async listAdminReports(filters = {}) {
  1261. const pagesize = Math.max(1, Math.min(100, Number(filters.pagesize || 20)))
  1262. const current = Math.max(1, Number(filters.current || 1))
  1263. const where = ['l.event IN (?, ?, ?, ?)']
  1264. const params = ['request_result', 'progress_snapshot', 'grab_success', 'grab_fail']
  1265. const countParams = ['request_result', 'progress_snapshot', 'grab_success', 'grab_fail']
  1266. if (filters.task_id) {
  1267. where.push('l.task_id = ?')
  1268. params.push(Number(filters.task_id))
  1269. countParams.push(Number(filters.task_id))
  1270. }
  1271. if (filters.client_id) {
  1272. where.push('l.client_id = ?')
  1273. params.push(String(filters.client_id))
  1274. countParams.push(String(filters.client_id))
  1275. }
  1276. if (filters.event) {
  1277. where.push('l.event = ?')
  1278. params.push(String(filters.event))
  1279. countParams.push(String(filters.event))
  1280. }
  1281. if (filters.start_time) {
  1282. where.push('l.create_time >= ?')
  1283. params.push(Number(filters.start_time))
  1284. countParams.push(Number(filters.start_time))
  1285. }
  1286. if (filters.end_time) {
  1287. where.push('l.create_time <= ?')
  1288. params.push(Number(filters.end_time))
  1289. countParams.push(Number(filters.end_time))
  1290. }
  1291. if (filters.student_num) {
  1292. where.push('t.student_num LIKE ?')
  1293. params.push(`%${filters.student_num}%`)
  1294. countParams.push(`%${filters.student_num}%`)
  1295. }
  1296. if (filters.name || filters.task_name) {
  1297. where.push('t.name LIKE ?')
  1298. params.push(`%${filters.name || filters.task_name}%`)
  1299. countParams.push(`%${filters.name || filters.task_name}%`)
  1300. }
  1301. if (filters.username) {
  1302. where.push('u.username COLLATE utf8mb4_general_ci LIKE (CONVERT(? USING utf8mb4) COLLATE utf8mb4_general_ci)')
  1303. params.push(`%${filters.username}%`)
  1304. countParams.push(`%${filters.username}%`)
  1305. }
  1306. if (filters.client_label) {
  1307. where.push('c.label LIKE ?')
  1308. params.push(`%${filters.client_label}%`)
  1309. countParams.push(`%${filters.client_label}%`)
  1310. }
  1311. const whereSql = where.join(' AND ')
  1312. const offset = (current - 1) * pagesize
  1313. const joinSql = `
  1314. FROM qk_task_log l
  1315. LEFT JOIN qk_task t ON t.id = l.task_id
  1316. LEFT JOIN qk_client c ON c.client_id = l.client_id
  1317. LEFT JOIN users u ON u.uuid COLLATE utf8mb4_general_ci = t.create_user COLLATE utf8mb4_general_ci`
  1318. const countRows = await db.query(
  1319. `SELECT COUNT(*) AS total ${joinSql} WHERE ${whereSql}`,
  1320. countParams
  1321. )
  1322. const rows = await db.query(
  1323. `SELECT l.id, l.task_id, l.client_id, l.event, l.message, l.payload_json, l.create_time,
  1324. t.name AS task_name, t.student_num, t.status AS task_status,
  1325. c.label AS client_label, u.username, u.avatar
  1326. ${joinSql}
  1327. WHERE ${whereSql}
  1328. ORDER BY l.create_time DESC
  1329. LIMIT ${pagesize} OFFSET ${offset}`,
  1330. params
  1331. )
  1332. return {
  1333. list: (rows || []).map(row => this.serializeReportLog(row)),
  1334. total: countRows?.[0]?.total || 0,
  1335. current,
  1336. pagesize
  1337. }
  1338. }
  1339. async reassignStaleRunningTasks() {
  1340. const onlineCount = await this.countOnlineClients()
  1341. if (onlineCount <= 1) {
  1342. return 0
  1343. }
  1344. const now = Date.now()
  1345. const staleBefore = now - this.staleTaskMs
  1346. const rows = await db.query(
  1347. `SELECT id, assigned_client_id FROM qk_task
  1348. WHERE status IN (?, ?)
  1349. AND assigned_at IS NOT NULL
  1350. AND assigned_at < ?`,
  1351. [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, staleBefore]
  1352. )
  1353. let count = 0
  1354. for (const row of rows || []) {
  1355. const result = await db.query(
  1356. `UPDATE qk_task
  1357. SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL,
  1358. assigned_at = NULL, exclude_client_id = ?, update_time = ?
  1359. WHERE id = ? AND status IN (?, ?)`,
  1360. [TASK_STATUS.PENDING, row.assigned_client_id, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1361. )
  1362. if (result && result.affectedRows > 0) {
  1363. count += 1
  1364. if (row.assigned_client_id) {
  1365. await db.query(
  1366. 'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
  1367. [now, row.assigned_client_id]
  1368. )
  1369. }
  1370. await this.logTask(row.id, row.assigned_client_id, 'reassigned', '任务超过1小时未成功,已收回并等待分配给其他客户端')
  1371. this.logWarn('reassignStale', '长时间未成功任务已收回', {
  1372. taskId: row.id,
  1373. clientId: row.assigned_client_id
  1374. }, { online_clients: onlineCount })
  1375. }
  1376. }
  1377. return count
  1378. }
  1379. async requeueExpiredTasks() {
  1380. const now = Date.now()
  1381. const rows = await db.query(
  1382. 'SELECT id, assigned_client_id FROM qk_task WHERE status IN (?, ?) AND lease_expire_at IS NOT NULL AND lease_expire_at < ?',
  1383. [TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING, now]
  1384. )
  1385. let count = 0
  1386. for (const row of rows || []) {
  1387. const result = await db.query(
  1388. 'UPDATE qk_task SET status = ?, assigned_client_id = NULL, lease_expire_at = NULL, assigned_at = NULL, update_time = ? WHERE id = ? AND status IN (?, ?)',
  1389. [TASK_STATUS.PENDING, now, row.id, TASK_STATUS.ASSIGNED, TASK_STATUS.RUNNING]
  1390. )
  1391. if (result && result.affectedRows > 0) {
  1392. count++
  1393. if (row.assigned_client_id) {
  1394. await db.query(
  1395. 'UPDATE qk_client SET current_slots = IF(current_slots > 0, current_slots - 1, 0), update_time = ? WHERE client_id = ?',
  1396. [now, row.assigned_client_id]
  1397. )
  1398. }
  1399. await this.logTask(row.id, row.assigned_client_id, 'reassigned', '租约过期,任务已重新进入待分配队列')
  1400. this.logWarn('requeueExpired', '租约过期,任务已重新入队', {
  1401. taskId: row.id,
  1402. clientId: row.assigned_client_id
  1403. })
  1404. }
  1405. }
  1406. const offlineResult = await db.query(
  1407. 'UPDATE qk_client SET online = 0, current_slots = 0, update_time = ? WHERE online = 1 AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)',
  1408. [now, now - this.heartbeatTtlSeconds * 1000]
  1409. )
  1410. const offlineCount = offlineResult?.affectedRows || 0
  1411. const staleCount = await this.reassignStaleRunningTasks()
  1412. if (count > 0 || offlineCount > 0 || staleCount > 0) {
  1413. this.logInfo('requeueExpired', '租约巡检完成', {}, {
  1414. expired_tasks: (rows || []).length,
  1415. requeued: count,
  1416. stale_reassigned: staleCount,
  1417. clients_marked_offline: offlineCount
  1418. })
  1419. }
  1420. return count + staleCount
  1421. }
  1422. }
  1423. module.exports = {
  1424. TaskScheduler,
  1425. TASK_STATUS
  1426. }