TaskScheduler.js 61 KB

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